diff --git a/.gitignore b/.gitignore index ee53b660..8b370d98 100644 --- a/.gitignore +++ b/.gitignore @@ -365,3 +365,15 @@ SW.Bitween.NativeAdapters/JsonFieldMapper/Bitween-api.code-workspace .vscode/ .postman/ postman/ + +# Admin UI build output (generated by ClientApp `yarn build` into wwwroot) +SW.Bitween.Web/wwwroot/index.html +SW.Bitween.Web/wwwroot/assets/ +SW.Bitween.Web/wwwroot/brand/ +SW.Bitween.Web/wwwroot/favicon.svg +SW.Bitween.Web/wwwroot/icons.svg + +# Playwright MCP tool output and test-run artifacts (created at repo root when +# run from here, separate from ClientApp's own .gitignore'd copies) +/.playwright-mcp/ +/test-results/ diff --git a/Dockerfile b/Dockerfile index 63224453..602cb04a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,14 @@ WORKDIR /app EXPOSE 8080 EXPOSE 443 +# Build the admin UI (SPA) in its own stage; output lands in SW.Bitween.Web/wwwroot +FROM node:22-alpine AS ui-build +WORKDIR /src/SW.Bitween.Web/ClientApp +COPY ["SW.Bitween.Web/ClientApp/package.json", "SW.Bitween.Web/ClientApp/yarn.lock", "./"] +RUN yarn install --frozen-lockfile +COPY ["SW.Bitween.Web/ClientApp/", "./"] +RUN yarn build + FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["SW.Bitween.Web/SW.Bitween.Web.csproj", "SW.Bitween.Web/"] @@ -21,9 +29,11 @@ WORKDIR "/src/SW.Bitween.Web" RUN dotnet build "SW.Bitween.Web.csproj" -c Release -o /app/build FROM build AS publish -RUN dotnet publish "SW.Bitween.Web.csproj" -c Release -o /app/publish +# UI is built in the ui-build stage; the SDK image has no node +RUN dotnet publish "SW.Bitween.Web.csproj" -c Release -o /app/publish -p:SkipClientBuild=true FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"] \ No newline at end of file +COPY --from=ui-build /src/SW.Bitween.Web/wwwroot ./wwwroot +ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"] diff --git a/SW.Bitween.Api/Controllers/GatewayController.cs b/SW.Bitween.Api/Controllers/GatewayController.cs index 43973b87..2debf48e 100644 --- a/SW.Bitween.Api/Controllers/GatewayController.cs +++ b/SW.Bitween.Api/Controllers/GatewayController.cs @@ -63,7 +63,7 @@ private async Task ProcessAsync([FromRoute] string gatewayApiName var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); - var xchangeFile = new XchangeFile(json); + var xchangeFile = new XchangeFile(json, $"{gatewayApiName}.json"); var validatorProperties = subscription.ValidatorProperties.ToDictionary() .Fill(partner, globalAdapterValuesSet); diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index cadd4704..200e098b 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -41,12 +41,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(b => { b.ToTable("Documents"); - b.Property(p => p.Id).ValueGeneratedNever(); b.Property(p => p.Name).HasMaxLength(100).IsUnicode(false).IsRequired(); + b.Property(p => p.Code).HasMaxLength(50).IsUnicode(false); b.Property(p => p.BusMessageTypeName).IsUnicode(false).HasMaxLength(500); b.Property(p => p.PromotedProperties).StoreAsJson(); b.Property(p => p.DisregardsUnfilteredMessages).IsRequired(false); b.HasIndex(p => p.Name).IsUnique(); + b.HasIndex(p => p.Code).IsUnique(); b.HasIndex(p => p.BusMessageTypeName).IsUnique(); b.HasMany().WithOne().HasForeignKey(p => p.DocumentId).OnDelete(DeleteBehavior.Restrict); @@ -349,7 +350,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(p => p.Email).IsUnique(); b.Property(p => p.Email).IsUnicode(false).HasMaxLength(200); - b.Property(p => p.Phone).IsUnicode(false).HasMaxLength(20); b.Property(p => p.Password).IsUnicode(false).HasMaxLength(500); b.Property(p => p.DisplayName).IsRequired().HasMaxLength(200); @@ -382,9 +382,57 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.AccountId); b.Property(p => p.LoginMethod).HasConversion(); }); - + + modelBuilder.Entity(b => + { + b.ToTable("Roles"); + b.HasKey(p => p.Id); + b.Property(p => p.Id).ValueGeneratedOnAdd(); + b.HasIndex(p => p.Name).IsUnique(); + + b.Property(p => p.Name).IsRequired().HasMaxLength(100); + b.Property(p => p.Description).HasMaxLength(500); + b.Property(p => p.Permissions).StoreAsJson(); + + b.HasData(SystemRoleSeed()); + }); + + modelBuilder.Entity(b => + { + b.ToTable("AccountRoles"); + b.HasKey(p => new { p.AccountId, p.RoleId }); + b.HasOne().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade); + b.HasOne().WithMany().HasForeignKey(p => p.RoleId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(b => + { + b.ToTable("Settings"); + b.HasKey(p => p.Id); + // Id is the catalog key, e.g. "Theme.PrimaryColor". Value is left unbounded: + // it carries anything from a hex color to a license key or a page of blurb. + b.Property(p => p.Id).IsUnicode(false).HasMaxLength(200); + }); + } + /// + /// The built-in roles. Their grants aren't stored — + /// derives them from the catalog — so adding a permission never needs a data fix-up here. + /// + protected Role[] SystemRoleSeed() => + [ + new Role(Role.AdministratorId, "Administrator", + "Full access to everything, including members, roles and settings.") + { CreatedOn = defaultCreatedOn.ToUniversalTime() }, + new Role(Role.MemberId, "Member", + "Runs and configures integrations. Can't manage members, roles or settings.") + { CreatedOn = defaultCreatedOn.ToUniversalTime() }, + new Role(Role.ViewerId, "Viewer", + "Read-only access to integrations, exchanges and configuration.") + { CreatedOn = defaultCreatedOn.ToUniversalTime() } + ]; + async public override Task SaveChangesAsync(CancellationToken cancellationToken = default) { ChangeTracker.ApplyAuditValues(requestContext.GetNameIdentifier()); diff --git a/SW.Bitween.Api/Domain/Accounts/Account.cs b/SW.Bitween.Api/Domain/Accounts/Account.cs index a2bb0d0b..bbd28832 100644 --- a/SW.Bitween.Api/Domain/Accounts/Account.cs +++ b/SW.Bitween.Api/Domain/Accounts/Account.cs @@ -18,7 +18,6 @@ public Account(string displayName, string email, string password, AccountRole ro } public string Email { get; private set; } - public string Phone { get; private set; } public string DisplayName { get; set; } public AccountRole Role { get; private set; } @@ -63,6 +62,26 @@ public void Update(string name, AccountRole role) Role = role; } + /// Self-service details. Deliberately cannot touch . + public void UpdateProfile(string name) + { + DisplayName = name; + } + + /// + /// Legacy coarse role, kept in step with the account's roles for older API consumers. + /// Authorization itself reads the role assignments, not this. + /// + public void SetRole(AccountRole role) + { + Role = role; + } + + public void SetDisabled(bool disabled) + { + Disabled = disabled; + } + public DateTime CreatedOn { get; set; } public string CreatedBy { get; set; } public DateTime? ModifiedOn { get; set; } diff --git a/SW.Bitween.Api/Domain/Accounts/AccountRoleLink.cs b/SW.Bitween.Api/Domain/Accounts/AccountRoleLink.cs new file mode 100644 index 00000000..acc1047a --- /dev/null +++ b/SW.Bitween.Api/Domain/Accounts/AccountRoleLink.cs @@ -0,0 +1,18 @@ +namespace SW.Bitween.Domain.Accounts; + +/// Which roles an account holds. Composite key — one row per account/role pair. +public class AccountRoleLink +{ + private AccountRoleLink() + { + } + + public AccountRoleLink(int accountId, int roleId) + { + AccountId = accountId; + RoleId = roleId; + } + + public int AccountId { get; private set; } + public int RoleId { get; private set; } +} diff --git a/SW.Bitween.Api/Domain/Accounts/Role.cs b/SW.Bitween.Api/Domain/Accounts/Role.cs new file mode 100644 index 00000000..99db343e --- /dev/null +++ b/SW.Bitween.Api/Domain/Accounts/Role.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Domain.Accounts; + +/// +/// A reusable set of permission keys. Members hold any number of roles and may do anything +/// any of their roles allows. +/// +public class Role : BaseEntity, IAudited +{ + public const int AdministratorId = 1; + public const int MemberId = 2; + public const int ViewerId = 3; + + /// The groups the built-in Member and Viewer roles reach; Administration stays admin-only. + private static readonly string[] NonAdminGroups = ["Operate", "Integrations", "Configuration"]; + + private Role() + { + } + + /// Seeding path for the built-in roles — fixed Id, grants computed at runtime. + public Role(int id, string name, string description) + { + Id = id; + Name = name; + Description = description; + IsSystem = true; + } + + public Role(string name, string description, IEnumerable permissions) + { + Name = name; + Description = description; + Permissions = PermissionCatalog.Sanitize(permissions); + } + + public string Name { get; private set; } + public string Description { get; private set; } + + /// + /// Catalog keys, stored as one JSON column. Empty for built-in roles — see + /// . + /// + public List Permissions { get; private set; } = []; + + /// Built-in roles can be assigned, but never edited or deleted. + public bool IsSystem { get; private set; } + + public void Update(string name, string description, IEnumerable permissions) + { + Name = name; + Description = description; + Permissions = PermissionCatalog.Sanitize(permissions); + } + + /// + /// What this role actually grants. Built-in roles derive their grants from the catalog at + /// runtime instead of storing them, so adding a permission to the catalog reaches + /// Administrators — and the right groups — without a migration or a data fix-up. + /// + public List GetEffectivePermissions() => IsSystem ? SystemPermissions(Id) : Permissions; + + public static List SystemPermissions(int roleId) => roleId switch + { + AdministratorId => PermissionCatalog.AllKeys.ToList(), + MemberId => PermissionCatalog.InGroups(false, NonAdminGroups), + ViewerId => PermissionCatalog.InGroups(true, NonAdminGroups), + _ => [] + }; + + public DateTime CreatedOn { get; set; } + public string CreatedBy { get; set; } + public DateTime? ModifiedOn { get; set; } + public string ModifiedBy { get; set; } +} diff --git a/SW.Bitween.Api/Domain/Document/Document.cs b/SW.Bitween.Api/Domain/Document/Document.cs index c9e4147b..e12a33eb 100644 --- a/SW.Bitween.Api/Domain/Document/Document.cs +++ b/SW.Bitween.Api/Domain/Document/Document.cs @@ -24,7 +24,17 @@ public Document(int id, string name, DocumentFormat format) DocumentFormat = format; } + /// Production creation path — Id is database-generated. Code is optional. + public Document(string code, string name, DocumentFormat format) + { + Code = code; + Name = name ?? throw new ArgumentNullException(nameof(name)); + PromotedProperties = new Dictionary(); + DocumentFormat = format; + } + public string Name { get; private set; } + public string Code { get; private set; } public bool BusEnabled { get; set; } public string BusMessageTypeName { get; set; } public int DuplicateInterval { get; set; } @@ -37,5 +47,15 @@ public void SetDictionaries(IReadOnlyDictionary promotedProperti { PromotedProperties = promotedProperties; } + + public void SetName(string name) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + public void SetCode(string code) + { + Code = code; + } } } \ No newline at end of file diff --git a/SW.Bitween.Api/Domain/Setting.cs b/SW.Bitween.Api/Domain/Setting.cs new file mode 100644 index 00000000..61d566e8 --- /dev/null +++ b/SW.Bitween.Api/Domain/Setting.cs @@ -0,0 +1,26 @@ +using System; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Domain; + +/// +/// One instance-wide setting, keyed by the setting's catalog key (e.g. Theme.PrimaryColor). +/// This table is the single source of truth: configuration seeds a key once — on the first boot +/// after that key exists — and is ignored for it from then on. Every catalog key normally has a +/// row; "reset to default" rewrites the row with the product default rather than removing it. +/// +/// Deliberately a plain key/value store: the definition of a key (label, section, type, +/// whether it's a secret) lives in , so adding a +/// setting never needs a migration or a data fix-up. A secret's value is encrypted before it +/// gets here — see . +/// +/// +public class Setting : BaseEntity, IAudited +{ + public string Value { get; set; } + + public DateTime CreatedOn { get; set; } + public string CreatedBy { get; set; } + public DateTime? ModifiedOn { get; set; } + public string ModifiedBy { get; set; } +} diff --git a/SW.Bitween.Api/Domain/Subscription/Schedule.cs b/SW.Bitween.Api/Domain/Subscription/Schedule.cs index fe4909db..eaed7744 100644 --- a/SW.Bitween.Api/Domain/Subscription/Schedule.cs +++ b/SW.Bitween.Api/Domain/Subscription/Schedule.cs @@ -57,7 +57,11 @@ public override int GetHashCode() public DateTime Next(DateTime? currentDate = null) { - var utcNow = currentDate == null ? DateTime.UtcNow : currentDate.Value; + // On.Hours/On.Minutes are UTC wall-clock time — the Quartz cron trigger that + // actually fires this schedule is explicitly pinned to TimeZoneInfo.Utc + // (see SW.Scheduler's WithCronSchedule(...).InTimeZone(TimeZoneInfo.Utc) call + // sites), so this must agree rather than each side defaulting independently. + var utcNow = currentDate ?? DateTime.UtcNow; DateTime nextSelectedDate; switch (Recurrence) diff --git a/SW.Bitween.Api/Domain/Xchange/Xchange.cs b/SW.Bitween.Api/Domain/Xchange/Xchange.cs index e9389a36..e051fa49 100644 --- a/SW.Bitween.Api/Domain/Xchange/Xchange.cs +++ b/SW.Bitween.Api/Domain/Xchange/Xchange.cs @@ -76,15 +76,16 @@ public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnl } //retry with reset subscription properties - public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IReadOnlyDictionary groupAttemptCounts = null) : + public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, Partner gatewayPartner = null, + GlobalAdapterValuesSet[] globalAdapterValuesSets = null, IReadOnlyDictionary groupAttemptCounts = null) : this(xchange.DocumentId, subscription.WorkGroup, file, xchange.References) { SubscriptionId = xchange.SubscriptionId; PartnerId = xchange.PartnerId ?? subscription.PartnerId; MapperId = subscription.MapperId; HandlerId = subscription.HandlerId; - MapperProperties = subscription.MapperProperties; - HandlerProperties = subscription.HandlerProperties; + MapperProperties = (subscription.MapperProperties ?? new Dictionary()).ToDictionary().Fill(gatewayPartner, globalAdapterValuesSets); + HandlerProperties = (subscription.HandlerProperties ?? new Dictionary()).ToDictionary().Fill(gatewayPartner, globalAdapterValuesSets); ResponseSubscriptionId = subscription.ResponseSubscriptionId; RetryFor = xchange.Id; CorrelationId = xchange.CorrelationId; diff --git a/SW.Bitween.Api/Extensions/AccountExtensions.cs b/SW.Bitween.Api/Extensions/AccountExtensions.cs index a0c3a6f5..a647579e 100644 --- a/SW.Bitween.Api/Extensions/AccountExtensions.cs +++ b/SW.Bitween.Api/Extensions/AccountExtensions.cs @@ -78,9 +78,6 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe case LoginMethod.EmailAndPassword: claims.Add(new Claim(ClaimTypes.Name, account.Email)); break; - case LoginMethod.PhoneAndOtp: - claims.Add(new Claim(ClaimTypes.Name, account.Phone)); - break; case LoginMethod.ApiKey: claims.Add(new Claim(ClaimTypes.Name, account.Id.ToString())); break; @@ -91,7 +88,6 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe } if (account.Email != null) claims.Add(new Claim(ClaimTypes.Email, account.Email)); - if (account.Phone != null) claims.Add(new Claim(ClaimTypes.MobilePhone, account.Phone)); return new ClaimsIdentity(claims, "Bitween"); diff --git a/SW.Bitween.Api/Extensions/ClaimsPrincipalExtensions.cs b/SW.Bitween.Api/Extensions/ClaimsPrincipalExtensions.cs deleted file mode 100644 index ec5900b7..00000000 --- a/SW.Bitween.Api/Extensions/ClaimsPrincipalExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Security.Claims; -using SW.Bitween.Domain.Accounts; - -namespace SW.Bitween -{ - static class ClaimsPrincipalExtensions - { - public static AccountRole? GetRole(this ClaimsPrincipal claimsPrincipal) - { - var role = claimsPrincipal?.FindFirst("Role"); - if (role is null) - return null; - return Enum.Parse(role.Value); - } - } -} \ No newline at end of file diff --git a/SW.Bitween.Api/Extensions/RequestContextExtensions.cs b/SW.Bitween.Api/Extensions/RequestContextExtensions.cs index 32de6bfe..93f5bb8f 100644 --- a/SW.Bitween.Api/Extensions/RequestContextExtensions.cs +++ b/SW.Bitween.Api/Extensions/RequestContextExtensions.cs @@ -1,17 +1,77 @@ +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; using SW.PrimitiveTypes; namespace SW.Bitween { public static class RequestContextExtensions { - public static void EnsureAccess(this RequestContext requestContext, params AccountRole[] allowedRoles) + /// + /// Marks the break-glass token minted by POST /login from configured AdminCredentials. That + /// token has no account behind it, so its grants can't be resolved from the database. It + /// used to clear every check only because the old role guard failed open on a missing + /// claim; this claim makes the same grant deliberate instead of accidental. + /// + public const string SuperuserClaim = "bitween_superuser"; + + /// + /// Throws unless the caller holds at least one of . This is really a + /// "forbidden" — the caller is signed in and simply isn't allowed — but CqApi renders + /// SWForbiddenException as a 401 that's byte-identical to sending no token, so there is no + /// distinction to be had on the wire. The client answers a 401 by refreshing the token and + /// retrying once, which means every denial costs a wasted round trip. Worth revisiting if + /// CqApi ever maps forbidden to 403. + /// + public static async Task EnsurePermission(this RequestContext requestContext, BitweenDbContext dbContext, + params string[] anyOf) { + var granted = await requestContext.GetPermissions(dbContext); + if (!anyOf.Any(granted.Contains)) + throw new SWUnauthorizedException("INSUFFICIENT_PERMISSIONS"); + } - var jobRole = requestContext.User.GetRole(); - if (jobRole is not null && allowedRoles.All(a => a != jobRole)) + public static async Task HasPermission(this RequestContext requestContext, BitweenDbContext dbContext, + string permission) + { + var granted = await requestContext.GetPermissions(dbContext); + return granted.Contains(permission); + } + + /// + /// The union of every permission the caller's roles grant. Resolved from the database on + /// each call rather than carried in the token, so revoking a role takes effect immediately + /// instead of at token expiry. Handlers guard once, so that's one small indexed query. + /// + public static async Task> GetPermissions(this RequestContext requestContext, + BitweenDbContext dbContext) + { + if (requestContext.User?.FindFirst(SuperuserClaim) is not null) + return PermissionCatalog.AllKeys.ToHashSet(); + + // Fail closed: no identifiable account means no grants. + if (!int.TryParse(requestContext.GetNameIdentifier(), out var accountId)) throw new SWUnauthorizedException("INSUFFICIENT_PERMISSIONS"); + + return await GetPermissionsOf(dbContext, accountId); + } + + public static async Task> GetPermissionsOf(BitweenDbContext dbContext, int accountId) + { + var roles = await (from link in dbContext.Set() + join role in dbContext.Set() on link.RoleId equals role.Id + where link.AccountId == accountId + select new { role.Id, role.IsSystem, role.Permissions }) + .AsNoTracking() + .ToListAsync(); + + var granted = new HashSet(); + foreach (var role in roles) + granted.UnionWith(role.IsSystem ? Role.SystemPermissions(role.Id) : role.Permissions ?? []); + return granted; } } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Resources/Accounts/AccountRoles.cs b/SW.Bitween.Api/Resources/Accounts/AccountRoles.cs new file mode 100644 index 00000000..6f6117e5 --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/AccountRoles.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +internal static class AccountRoles +{ + /// Role summaries for a set of accounts, in one query — keyed by account id. + public static async Task>> For(BitweenDbContext dbContext, + List accountIds) + { + var links = await (from link in dbContext.Set() + join role in dbContext.Set() on link.RoleId equals role.Id + where accountIds.Contains(link.AccountId) + select new { link.AccountId, role.Id, role.Name }) + .AsNoTracking() + .ToListAsync(); + + return links + .GroupBy(l => l.AccountId) + .ToDictionary( + g => g.Key, + g => g.OrderBy(l => l.Name) + .Select(l => new AccountRoleSummary { Id = l.Id, Name = l.Name }) + .ToList()); + } + + public static async Task> Of(BitweenDbContext dbContext, int accountId) => + (await For(dbContext, [accountId])).GetValueOrDefault(accountId, []); + + /// + /// Replaces an account's roles. Rejects unknown ids so a stale UI can't silently strip access. + /// + public static async Task Set(BitweenDbContext dbContext, int accountId, List roleIds) + { + var wanted = (roleIds ?? []).Distinct().ToList(); + + var known = await dbContext.Set().Where(r => wanted.Contains(r.Id)).Select(r => r.Id).ToListAsync(); + var unknown = wanted.Except(known).ToList(); + if (unknown.Count > 0) + throw new SWValidationException("ROLE_NOT_FOUND", + $"These roles don't exist: {string.Join(", ", unknown)}."); + + var existing = await dbContext.Set().Where(l => l.AccountId == accountId).ToListAsync(); + + foreach (var link in existing.Where(l => !wanted.Contains(l.RoleId))) + dbContext.Remove(link); + + foreach (var roleId in wanted.Except(existing.Select(l => l.RoleId))) + dbContext.Add(new AccountRoleLink(accountId, roleId)); + } + + /// + /// Keeps the legacy column meaningful for older API consumers. It no + /// longer drives authorization — it's derived from whichever built-in role the member holds, + /// falling back to Member for someone who only holds custom roles. + /// + public static AccountRole LegacyRoleFor(List roleIds) + { + if (roleIds.Contains(Role.AdministratorId)) return AccountRole.Admin; + if (roleIds.Contains(Role.MemberId)) return AccountRole.Member; + if (roleIds.Count == 0 || roleIds.Contains(Role.ViewerId)) return AccountRole.Viewer; + return AccountRole.Member; + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/Administrators.cs b/SW.Bitween.Api/Resources/Accounts/Administrators.cs new file mode 100644 index 00000000..fbd18109 --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/Administrators.cs @@ -0,0 +1,38 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +internal static class Administrators +{ + /// + /// How many other enabled accounts still hold the Administrator role. Guards every operation + /// that could otherwise leave an instance with nobody able to manage members and roles. + /// + public static Task OtherThan(BitweenDbContext dbContext, int accountId) => + (from link in dbContext.Set() + join account in dbContext.Set() on link.AccountId equals account.Id + where link.RoleId == Role.AdministratorId && link.AccountId != accountId && !account.Disabled + select link.AccountId).CountAsync(); + + public static Task Holds(BitweenDbContext dbContext, int accountId) => + dbContext.Set() + .AnyAsync(l => l.AccountId == accountId && l.RoleId == Role.AdministratorId); + + /// + /// Blocks an operation that would remove the last administrator. Only applies when the account + /// actually holds the role — otherwise nothing is being taken away. + /// + public static async Task EnsureNotTheLast(BitweenDbContext dbContext, int accountId) + { + if (!await Holds(dbContext, accountId)) + return; + + if (await OtherThan(dbContext, accountId) == 0) + throw new SWValidationException("LAST_ADMINISTRATOR", + "This is the only member with the Administrator role. Give it to someone else first."); + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs b/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs index 40ade9aa..7c6e8800 100644 --- a/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs +++ b/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs @@ -21,8 +21,8 @@ public ChangePassword(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(ChangePasswordModel request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member, AccountRole.Viewer); - + // Self-service: this only ever changes the caller's own password, and the old one has to + // be supplied. The guard it replaces listed every role, so it granted nothing. var accountId = Convert.ToInt32(_requestContext.GetNameIdentifier()); var account = await _dbContext.Set().FindAsync(accountId); diff --git a/SW.Bitween.Api/Resources/Accounts/Create.cs b/SW.Bitween.Api/Resources/Accounts/Create.cs index f8e10142..173f3b14 100644 --- a/SW.Bitween.Api/Resources/Accounts/Create.cs +++ b/SW.Bitween.Api/Resources/Accounts/Create.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading.Tasks; using FluentValidation; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext, Bitween public async Task Handle(CreateAccountModel request) { - _requestContext.EnsureAccess(AccountRole.Admin); + await _requestContext.EnsurePermission(dbContext, Model.Permissions.Users.Create); if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.Email) || (!_bitweenOptions.DisableEmailPasswordLogin && string.IsNullOrEmpty(request.Password))) @@ -32,6 +33,17 @@ public async Task Handle(CreateAccountModel request) if (await dbContext.Set().AnyAsync(a => a.Email == request.Email)) throw new SWValidationException("ACCOUNT_EXISTS", $"Account with email {request.Email} exists"); + // Prefer explicit role ids, and fall back to the legacy coarse role only when one was + // actually sent. It used to be a plain int, so omitting it meant 0 — which is Admin — + // and adding a member with no roles ticked handed them the run of the instance. + var roleIds = request.RoleIds is { Count: > 0 } + ? request.RoleIds.Distinct().ToList() + : request.Role is null + ? [] + : [BuiltInRoleFor((AccountRole)request.Role)]; + + // 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 ? null : SecurePasswordHasher.Hash(request.Password); @@ -40,14 +52,23 @@ public async Task Handle(CreateAccountModel request) request.Name, request.Email, password, - (AccountRole)request.Role); + AccountRoles.LegacyRoleFor(roleIds)); dbContext.Add(newAccount); + await dbContext.SaveChangesAsync(); + await AccountRoles.Set(dbContext, newAccount.Id, roleIds); await dbContext.SaveChangesAsync(); - return null; + return newAccount.Id; } + private static int BuiltInRoleFor(AccountRole role) => role switch + { + AccountRole.Admin => Role.AdministratorId, + AccountRole.Member => Role.MemberId, + _ => Role.ViewerId + }; + private class Validate : AbstractValidator { public Validate(BitweenOptions bitweenOptions) @@ -55,7 +76,6 @@ public Validate(BitweenOptions bitweenOptions) RuleFor(i => i.Name).NotEmpty(); RuleFor(i => i.Email).NotEmpty(); RuleFor(i => i.Password).NotEmpty().When(_ => !bitweenOptions.DisableEmailPasswordLogin); - RuleFor(i => i.Role).NotNull(); } } } diff --git a/SW.Bitween.Api/Resources/Accounts/Profile.cs b/SW.Bitween.Api/Resources/Accounts/Profile.cs index 18eced18..ce4cf093 100644 --- a/SW.Bitween.Api/Resources/Accounts/Profile.cs +++ b/SW.Bitween.Api/Resources/Accounts/Profile.cs @@ -8,6 +8,10 @@ namespace SW.Bitween.Resources.Accounts; +/// +/// Who am I and what may I do. Deliberately ungated — every signed-in caller needs it, and the +/// permissions it returns are what the UI uses to decide which pages and actions to show. +/// [HandlerName("profile")] public class Profile : IQueryHandler { @@ -23,16 +27,26 @@ public Profile(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle() { var accountId = Convert.ToInt32(requestContext.GetNameIdentifier()); - return await dbContext.Set() + + var profile = await dbContext.Set() .AsNoTracking() - .Select(a => new AccountModel + .Where(a => a.Id == accountId) + .Select(a => new ProfileModel { CreatedOn = a.CreatedOn, Email = a.Email, Name = a.DisplayName, Id = a.Id, + Disabled = a.Disabled, Role = a.Role.ToString() }) - .FirstOrDefaultAsync(a => a.Id == accountId); + .SingleOrDefaultAsync(); + + if (profile is null) + return null; + + profile.Roles = await AccountRoles.Of(dbContext, accountId); + profile.Permissions = (await requestContext.GetPermissions(dbContext)).OrderBy(p => p).ToList(); + return profile; } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Resources/Accounts/RemoveAccount.cs b/SW.Bitween.Api/Resources/Accounts/RemoveAccount.cs index a93a6b50..dabe8792 100644 --- a/SW.Bitween.Api/Resources/Accounts/RemoveAccount.cs +++ b/SW.Bitween.Api/Resources/Accounts/RemoveAccount.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using SW.Bitween.Domain.Accounts; using SW.PrimitiveTypes; @@ -18,13 +19,18 @@ public RemoveAccountModel(BitweenDbContext dbContext, RequestContext requestCont public async Task Handle(int key, RemoveAccountModel request) { - _requestContext.EnsureAccess(AccountRole.Admin); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Users.Delete); var account = await _dbContext.Set().FindAsync(key); if (account is null) throw new SWValidationException("ACCOUNT_NOT_FOUND", $"Account with {key} was not found"); + if (key == Convert.ToInt32(_requestContext.GetNameIdentifier())) + throw new SWValidationException("CANNOT_REMOVE_SELF", "You can't remove your own account."); + + await Administrators.EnsureNotTheLast(_dbContext, key); + _dbContext.Remove(account); await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/Accounts/Search.cs b/SW.Bitween.Api/Resources/Accounts/Search.cs index fd7afebc..be767dcd 100644 --- a/SW.Bitween.Api/Resources/Accounts/Search.cs +++ b/SW.Bitween.Api/Resources/Accounts/Search.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -7,22 +8,28 @@ namespace SW.Bitween.Resources.Accounts { - public class Search : IQueryHandler + public class Search : IQueryHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(SearchMembersModel request) { + // Lookup returns only id/name pairs, which document and integration trails use to turn + // createdBy ids into names; the member list itself is the data users.view covers. + if (!request.Lookup) + await requestContext.EnsurePermission(dbContext, Model.Permissions.Users.View); + request.Limit ??= 20; request.Offset ??= 0; var query = dbContext.Set().AsNoTracking().AsQueryable(); - if (request.Lookup) { return await query.OrderBy(i => i.DisplayName) @@ -40,10 +47,16 @@ public async Task Handle(SearchMembersModel request) Email = a.Email, Name = a.DisplayName, Id = a.Id, + Disabled = a.Disabled, Role = a.Role.ToString() }) .ToListAsync(); + var accountIds = accounts.Select(a => a.Id).ToList(); + var rolesByAccount = await AccountRoles.For(dbContext, accountIds); + + foreach (var account in accounts) + account.Roles = rolesByAccount.GetValueOrDefault(account.Id, []); return new { @@ -52,4 +65,4 @@ public async Task Handle(SearchMembersModel request) }; } } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Resources/Accounts/SetDisabled.cs b/SW.Bitween.Api/Resources/Accounts/SetDisabled.cs new file mode 100644 index 00000000..94185d31 --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/SetDisabled.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +/// +/// Suspends or restores an account. A disabled account keeps its roles and history but can't sign +/// in — see the Disabled check in the login handler. +/// +[HandlerName("setDisabled")] +public class SetDisabled : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public SetDisabled(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, SetAccountDisabledModel request) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Users.Edit); + + var account = await _dbContext.Set().FindAsync(key); + if (account is null) + throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}"); + + if (request.Disabled) + { + if (key == Convert.ToInt32(_requestContext.GetNameIdentifier())) + throw new SWValidationException("CANNOT_DISABLE_SELF", "You can't disable your own account."); + + await Administrators.EnsureNotTheLast(_dbContext, key); + } + + account.SetDisabled(request.Disabled); + await _dbContext.SaveChangesAsync(); + + return null; + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/SetPassword.cs b/SW.Bitween.Api/Resources/Accounts/SetPassword.cs new file mode 100644 index 00000000..65ef407e --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/SetPassword.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using FluentValidation; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +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. +/// Changing your own password goes through ChangePassword, which asks for the current one. +/// +[HandlerName("setPassword")] +public class SetPassword : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public SetPassword(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, SetAccountPasswordModel request) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Users.Edit); + + if (key == Convert.ToInt32(_requestContext.GetNameIdentifier())) + throw new SWValidationException("USE_CHANGE_PASSWORD", + "Use Change password to set your own, so the current one is still required."); + + var account = await _dbContext.Set().FindAsync(key); + if (account is null) + throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}"); + + account.SetPassword(request.Password); + await _dbContext.SaveChangesAsync(); + + return null; + } + + private class Validate : AbstractValidator + { + public Validate() + { + RuleFor(i => i.Password).NotEmpty().MinimumLength(8); + } + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/SetRoles.cs b/SW.Bitween.Api/Resources/Accounts/SetRoles.cs new file mode 100644 index 00000000..779b165b --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/SetRoles.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +/// Replaces the whole set of roles a member holds. +[HandlerName("setRoles")] +public class SetRoles : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public SetRoles(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, SetAccountRolesModel request) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Users.Edit); + + var account = await _dbContext.Set().FindAsync(key); + if (account is null) + throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}"); + + var roleIds = (request.RoleIds ?? []).Distinct().ToList(); + + // Don't let the last administrator be demoted — including by themselves. Otherwise an + // instance ends up with nobody able to manage members or roles. + if (!roleIds.Contains(Role.AdministratorId)) + await Administrators.EnsureNotTheLast(_dbContext, key); + + await AccountRoles.Set(_dbContext, key, roleIds); + account.SetRole(AccountRoles.LegacyRoleFor(roleIds)); + await _dbContext.SaveChangesAsync(); + + return null; + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/Update.cs b/SW.Bitween.Api/Resources/Accounts/Update.cs index d7b3a3e6..1a27b8a9 100644 --- a/SW.Bitween.Api/Resources/Accounts/Update.cs +++ b/SW.Bitween.Api/Resources/Accounts/Update.cs @@ -6,7 +6,7 @@ namespace SW.Bitween.Resources.Accounts; -public class Update : ICommandHandler +public class Update : ICommandHandler { private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; @@ -21,19 +21,35 @@ public async Task Handle(int key, UpdateAccountModel request) { var loggedInUserId = Convert.ToInt32(_requestContext.GetNameIdentifier()); + // Anyone may edit their own name; editing someone else needs the grant. if (key != loggedInUserId) - _requestContext.EnsureAccess(AccountRole.Admin); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Users.Edit); - - var account = await _dbContext.Set().FindAsync(key); if (account is null) throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}"); + account.UpdateProfile(request.Name); + + // Changing a role is never self-service, or anyone could promote themselves. + if (request.Role is not null && (AccountRole)request.Role != account.Role) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Users.Edit); + var role = (AccountRole)request.Role; + account.SetRole(role); + await AccountRoles.Set(_dbContext, key, [BuiltInRoleFor(role)]); + } - account.Update(request.Name, (AccountRole)request.Role); await _dbContext.SaveChangesAsync(); return null; } -} \ No newline at end of file + + /// Maps the legacy coarse role onto the built-in role that reproduces it. + private static int BuiltInRoleFor(AccountRole role) => role switch + { + AccountRole.Admin => Role.AdministratorId, + AccountRole.Member => Role.MemberId, + _ => Role.ViewerId + }; +} diff --git a/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs b/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs index 78e08409..764460ec 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs @@ -2,7 +2,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; using Microsoft.EntityFrameworkCore; using System.Linq; using SW.Bitween.Domain; @@ -23,7 +22,7 @@ public AddPartner(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int gatewayId, ApiGatewayPartnerCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.Edit); var gateway = await _dbContext.Set() .Include(ag => ag.Partners) diff --git a/SW.Bitween.Api/Resources/ApiGateways/Create.cs b/SW.Bitween.Api/Resources/ApiGateways/Create.cs index c4702745..60f633a8 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Create.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Create.cs @@ -2,7 +2,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.ApiGateways { @@ -19,7 +18,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(ApiGatewayCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.Create); if (string.IsNullOrWhiteSpace(model.UrlName)) throw new SWException("UrlName is required"); diff --git a/SW.Bitween.Api/Resources/ApiGateways/Delete.cs b/SW.Bitween.Api/Resources/ApiGateways/Delete.cs index 88143b5a..bea7de8a 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Delete.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Delete.cs @@ -1,8 +1,8 @@ +using Microsoft.EntityFrameworkCore; using SW.EfCoreExtensions; using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.ApiGateways { @@ -19,9 +19,21 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.Delete); - await _dbContext.DeleteByKeyAsync(key); + var gateway = await _dbContext.Set() + .Include(ag => ag.Partners) + .FirstOrDefaultAsync(ag => ag.Id == key); + + if (gateway == null) + throw new SWNotFoundException($"ApiGateway with Id {key} not found"); + + // Partners are FK-restricted to the gateway; remove them explicitly before the gateway. + if (gateway.Partners != null && gateway.Partners.Count > 0) + _dbContext.RemoveRange(gateway.Partners); + + _dbContext.Remove(gateway); + await _dbContext.SaveChangesAsync(); return null; } } diff --git a/SW.Bitween.Api/Resources/ApiGateways/Get.cs b/SW.Bitween.Api/Resources/ApiGateways/Get.cs index 0b935c0b..6d8ce6aa 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Get.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Get.cs @@ -10,14 +10,18 @@ namespace SW.Bitween.Resources.ApiGateways public class Get : IGetHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(int key) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.View); + var gateway = await _dbContext.Set() .AsNoTracking() .Include(ag => ag.Partners) diff --git a/SW.Bitween.Api/Resources/ApiGateways/RemovePartner.cs b/SW.Bitween.Api/Resources/ApiGateways/RemovePartner.cs index 610ac43d..1dbca5e8 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/RemovePartner.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/RemovePartner.cs @@ -1,7 +1,6 @@ using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; using Microsoft.EntityFrameworkCore; using System.Linq; @@ -21,7 +20,7 @@ public RemovePartner(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int gatewayId, RemovePartnerRequest request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.Edit); var gateway = await _dbContext.Set() .Include(ag => ag.Partners) diff --git a/SW.Bitween.Api/Resources/ApiGateways/Search.cs b/SW.Bitween.Api/Resources/ApiGateways/Search.cs index b22f722f..12db87cf 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Search.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Search.cs @@ -11,14 +11,21 @@ namespace SW.Bitween.Resources.ApiGateways public class Search : ISearchyHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.View); + var query = from gateway in _dbContext.Set() select new ApiGatewayRow { @@ -38,10 +45,35 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa // Apply ordering by Id descending query = query.OrderByDescending(g => g.Id); + var totalCount = await query.Search(searchyRequest.Conditions).CountAsync(); + var result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(); + + // The list screen needs full attachment detail up front (not just a + // count), so hydrate it with one grouped query instead of Get.cs's + // per-row Include (gateways are few, so this stays a single round trip). + var ids = result.Select(r => r.Id).ToList(); + var partnersByGateway = (await _dbContext.Set() + .AsNoTracking() + .Where(p => ids.Contains(p.ApiGatewayId)) + .Include(p => p.Partner) + .Include(p => p.Subscription) + .ToListAsync()) + .ToLookup(p => p.ApiGatewayId); + foreach (var row in result) + { + row.Partners = partnersByGateway[row.Id].Select(p => new ApiGatewayPartnerDto + { + PartnerId = p.PartnerId, + SubscriptionId = p.SubscriptionId, + PartnerName = p.Partner.Name, + SubscriptionName = p.Subscription.Name + }).ToList(); + } + return new SearchyResponse { - TotalCount = await query.Search(searchyRequest.Conditions).CountAsync(), - Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync() + TotalCount = totalCount, + Result = result }; } } diff --git a/SW.Bitween.Api/Resources/ApiGateways/Update.cs b/SW.Bitween.Api/Resources/ApiGateways/Update.cs index b53bef93..ddf88beb 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Update.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Update.cs @@ -3,7 +3,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; using Microsoft.EntityFrameworkCore; using System.Linq; @@ -22,7 +21,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key, ApiGatewayUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.Edit); var entity = await _dbContext.Set() .Include(ag => ag.Partners) diff --git a/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs b/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs index 87405abb..ac5b0733 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs @@ -2,7 +2,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; using Microsoft.EntityFrameworkCore; using System.Linq; using SW.Bitween.Domain; @@ -23,7 +22,7 @@ public UpdatePartner(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int gatewayId, ApiGatewayPartnerCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.Edit); var gateway = await _dbContext.Set() .Include(ag => ag.Partners) diff --git a/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs b/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs index ef53e451..57ac2bfd 100644 --- a/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs +++ b/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -24,7 +23,7 @@ public AddRoute(BitweenDbContext dbContext, RequestContext requestContext, IInfo public async Task Handle(int gatewayId, BusGatewayRouteCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.Edit); var gateway = await _dbContext.Set() .FirstOrDefaultAsync(bg => bg.Id == gatewayId); diff --git a/SW.Bitween.Api/Resources/BusGateways/Create.cs b/SW.Bitween.Api/Resources/BusGateways/Create.cs index bc7cd75e..ddf94b56 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Create.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Create.cs @@ -1,7 +1,6 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -24,7 +23,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext, IInfoli public async Task Handle(BusGatewayCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.Create); var documentExists = await _dbContext.Set().AnyAsync(d => d.Id == model.DocumentId); if (!documentExists) diff --git a/SW.Bitween.Api/Resources/BusGateways/Delete.cs b/SW.Bitween.Api/Resources/BusGateways/Delete.cs index 0fbacf90..9958ec0d 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Delete.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Delete.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; using System.Threading.Tasks; @@ -21,7 +20,7 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext, IInfoli public async Task Handle(int key) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.Delete); var gateway = await _dbContext.Set() .Include(bg => bg.Routes) diff --git a/SW.Bitween.Api/Resources/BusGateways/Get.cs b/SW.Bitween.Api/Resources/BusGateways/Get.cs index 2eefa3db..b9faeff7 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Get.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Get.cs @@ -11,14 +11,18 @@ namespace SW.Bitween.Resources.BusGateways public class Get : IGetHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(int key) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.View); + var gateway = await _dbContext.Set() .AsNoTracking() .Include(bg => bg.Routes) diff --git a/SW.Bitween.Api/Resources/BusGateways/RemoveRoute.cs b/SW.Bitween.Api/Resources/BusGateways/RemoveRoute.cs index e4ea6e6f..4f573df1 100644 --- a/SW.Bitween.Api/Resources/BusGateways/RemoveRoute.cs +++ b/SW.Bitween.Api/Resources/BusGateways/RemoveRoute.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -23,7 +22,7 @@ public RemoveRoute(BitweenDbContext dbContext, RequestContext requestContext, II public async Task Handle(int gatewayId, RemoveRouteRequest request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.Edit); var route = await _dbContext.Set() .FirstOrDefaultAsync(r => r.Id == request.RouteId && r.BusGatewayId == gatewayId); diff --git a/SW.Bitween.Api/Resources/BusGateways/Search.cs b/SW.Bitween.Api/Resources/BusGateways/Search.cs index 3210d3cd..62d8442c 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Search.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Search.cs @@ -12,14 +12,21 @@ namespace SW.Bitween.Resources.BusGateways public class Search : ISearchyHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.View); + var documents = _dbContext.Set(); var query = from gateway in _dbContext.Set() @@ -42,10 +49,37 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa query = query.OrderByDescending(g => g.Id); + var totalCount = await query.Search(searchyRequest.Conditions).CountAsync(); + var result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(); + + // The list screen needs full route detail up front (not just a count), + // so hydrate it with one grouped query instead of Get.cs's per-row + // Include (gateways are few, so this stays a single round trip). + var ids = result.Select(r => r.Id).ToList(); + var routesByGateway = (await _dbContext.Set() + .AsNoTracking() + .Where(r => ids.Contains(r.BusGatewayId)) + .Include(r => r.Subscription) + .Include(r => r.Partner) + .ToListAsync()) + .ToLookup(r => r.BusGatewayId); + foreach (var row in result) + { + row.Routes = routesByGateway[row.Id].Select(r => new BusGatewayRouteDto + { + Id = r.Id, + SubscriptionId = r.SubscriptionId, + SubscriptionName = r.Subscription != null ? r.Subscription.Name : null, + PartnerId = r.PartnerId, + PartnerName = r.Partner != null ? r.Partner.Name : null, + MatchExpression = r.MatchExpression + }).ToList(); + } + return new SearchyResponse { - TotalCount = await query.Search(searchyRequest.Conditions).CountAsync(), - Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync() + TotalCount = totalCount, + Result = result }; } } diff --git a/SW.Bitween.Api/Resources/BusGateways/Update.cs b/SW.Bitween.Api/Resources/BusGateways/Update.cs index 4aa50cbd..1235636f 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Update.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Update.cs @@ -1,6 +1,5 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -23,7 +22,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext, IInfoli public async Task Handle(int key, BusGatewayUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.Edit); var entity = await _dbContext.Set() .FirstOrDefaultAsync(bg => bg.Id == key); diff --git a/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs b/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs index 5bd79f38..32b6597d 100644 --- a/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs +++ b/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -23,7 +22,7 @@ public UpdateRoute(BitweenDbContext dbContext, RequestContext requestContext, II public async Task Handle(int gatewayId, BusGatewayRouteUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.BusGateways.Edit); var gateway = await _dbContext.Set() .FirstOrDefaultAsync(bg => bg.Id == gatewayId); diff --git a/SW.Bitween.Api/Resources/Dashboard/ChartDataPoints.cs b/SW.Bitween.Api/Resources/Dashboard/ChartDataPoints.cs index 8e13f439..6df53b00 100644 --- a/SW.Bitween.Api/Resources/Dashboard/ChartDataPoints.cs +++ b/SW.Bitween.Api/Resources/Dashboard/ChartDataPoints.cs @@ -11,16 +11,20 @@ namespace SW.Bitween.Resources.Dashboard; public class ChartsDataPoints : IQueryHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; private readonly DateTime _dataDateLimit; - public ChartsDataPoints(BitweenDbContext dbContext) + public ChartsDataPoints(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; _dataDateLimit = DateTime.UtcNow.AddMonths(-3); } public async Task Handle() { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Dashboard.View); + var xChangesPerDay = await _dbContext.Set() .AsNoTracking() .Where(i => i.StartedOn >= _dataDateLimit) diff --git a/SW.Bitween.Api/Resources/Dashboard/MainInfo.cs b/SW.Bitween.Api/Resources/Dashboard/MainInfo.cs index c819cffe..b2a36b4f 100644 --- a/SW.Bitween.Api/Resources/Dashboard/MainInfo.cs +++ b/SW.Bitween.Api/Resources/Dashboard/MainInfo.cs @@ -12,14 +12,18 @@ namespace SW.Bitween.Resources.Dashboard; public class MainInfo : IQueryHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public MainInfo(BitweenDbContext dbContext) + public MainInfo(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle() { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Dashboard.View); + var subscriptionsCount = await _dbContext.Set().AsNoTracking().CountAsync(); var documentCount = await _dbContext.Set().AsNoTracking().CountAsync(); var notifiersCount = await _dbContext.Set().AsNoTracking().CountAsync(); diff --git a/SW.Bitween.Api/Resources/Dashboard/XChangesAndSubscriptionsInfo.cs b/SW.Bitween.Api/Resources/Dashboard/XChangesAndSubscriptionsInfo.cs index f2eec15a..7214d584 100644 --- a/SW.Bitween.Api/Resources/Dashboard/XChangesAndSubscriptionsInfo.cs +++ b/SW.Bitween.Api/Resources/Dashboard/XChangesAndSubscriptionsInfo.cs @@ -14,6 +14,7 @@ namespace SW.Bitween.Resources.Dashboard; public class XChangesAndSubscriptionsInfo : IQueryHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; private readonly DateTime _dataDateLimit; private readonly XchangeService _xchangeService; @@ -21,9 +22,10 @@ public class XChangesAndSubscriptionsInfo : IQueryHandler // private readonly IMemoryCache _memoryCache; // private const string CACHE_KEY = "XChangesAndSubscriptionsInfoCache"; - public XChangesAndSubscriptionsInfo(BitweenDbContext dbContext, XchangeService xchangeService) + public XChangesAndSubscriptionsInfo(BitweenDbContext dbContext, XchangeService xchangeService, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; _xchangeService = xchangeService; //_memoryCache = memoryCache; _dataDateLimit = DateTime.UtcNow.AddMonths(-3); @@ -31,6 +33,8 @@ public XChangesAndSubscriptionsInfo(BitweenDbContext dbContext, XchangeService x public async Task Handle() { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Dashboard.View); + var totalXchangesCount = await _dbContext.Set().AsNoTracking().CountAsync(); var xChangeCountInTimeframe = await _dbContext.Set() .Where(i => i.StartedOn >= _dataDateLimit) diff --git a/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs b/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs index 9f287393..85c1b9d7 100644 --- a/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs +++ b/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs @@ -1,7 +1,6 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -23,7 +22,9 @@ public RunNow(BitweenDbContext dbContext, RequestContext requestContext, Xchange public async Task Handle(string key, DelayedRetryRunNow request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + // What's being operated on is an exchange, not the policy that scheduled the retry — + // and this is the same page the UI gates on exchange permissions. + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Exchanges.Operate); var delayedRetry = await _dbContext.Set().FirstOrDefaultAsync(d => d.Id == key); if (delayedRetry == null) diff --git a/SW.Bitween.Api/Resources/DelayedRetries/Search.cs b/SW.Bitween.Api/Resources/DelayedRetries/Search.cs index 4925351c..cc138c00 100644 --- a/SW.Bitween.Api/Resources/DelayedRetries/Search.cs +++ b/SW.Bitween.Api/Resources/DelayedRetries/Search.cs @@ -11,14 +11,21 @@ namespace SW.Bitween.Resources.DelayedRetries public class Search : ISearchyHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Exchanges.View, Model.Permissions.Dashboard.View); + var query = from delayedRetry in _dbContext.Set() join xchange in _dbContext.Set() on delayedRetry.Id equals xchange.Id join result in _dbContext.Set() on xchange.Id equals result.Id into xr @@ -35,7 +42,9 @@ from subscriber in xs.DefaultIfEmpty() DocumentId = xchange.DocumentId, DocumentName = document.Name, Exception = result.Exception, - StartedOn = xchange.StartedOn + StartedOn = xchange.StartedOn, + RetryPolicyId = subscriber.RetryPolicyId, + RetryPolicyName = subscriber.RetryPolicy.Name }; query = query.OrderBy(r => r.On).AsNoTracking(); diff --git a/SW.Bitween.Api/Resources/Documents/Create.cs b/SW.Bitween.Api/Resources/Documents/Create.cs index 033e9cd9..c05848cd 100644 --- a/SW.Bitween.Api/Resources/Documents/Create.cs +++ b/SW.Bitween.Api/Resources/Documents/Create.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Microsoft.EntityFrameworkCore; using SW.EfCoreExtensions; using SW.Bitween.Domain; using SW.Bitween.Model; @@ -8,7 +9,6 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Documents { @@ -16,22 +16,50 @@ public class Create : ICommandHandler { private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; + private readonly IBroadcast _broadcast; - public Create(BitweenDbContext dbContext, RequestContext requestContext) + public Create(BitweenDbContext dbContext, RequestContext requestContext, IBroadcast broadcast) { _dbContext = dbContext; _requestContext = requestContext; + _broadcast = broadcast; } public async Task Handle(DocumentCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Documents.Create); - var entity = new Document(model.Id, model.Name, model.DocumentFormat); + var code = string.IsNullOrWhiteSpace(model.Code) ? null : model.Code; + + if (code != null && await _dbContext.Set().AsNoTracking().AnyAsync(d => d.Code == code)) + throw new SWValidationException("CODE_TAKEN", "This code is already in use."); + + if (model.BusEnabled && !string.IsNullOrEmpty(model.BusMessageTypeName)) + { + var busTypeNameDuplicated = await _dbContext.Set() + .AsNoTracking() + .AnyAsync(d => d.BusMessageTypeName == model.BusMessageTypeName); + if (busTypeNameDuplicated) + throw new SWValidationException("DUPLICATED_BUS_TYPE_NAME", + "Cant use duplicated bus Message type name"); + } + + var entity = new Document(code, model.Name, model.DocumentFormat) + { + BusEnabled = model.BusEnabled, + BusMessageTypeName = model.BusEnabled ? model.BusMessageTypeName : null, + }; var trail = new DocumentTrail(DocumentTrailCode.Created, entity, true); _dbContext.Add(trail); _dbContext.Add(entity); await _dbContext.SaveChangesAsync(); + + // A bus-enabled type adds a queue, and the consumer set is only rebuilt when asked. + // Without this the queue is declared but nothing ever consumes it, until either an + // unrelated document update happens to refresh consumers or the app restarts. + if (entity.BusEnabled) + await _broadcast.RefreshConsumers(); + return entity.Id; } @@ -39,8 +67,15 @@ private class Validate : AbstractValidator { public Validate() { - RuleFor(i => i.Id).NotEmpty(); + RuleFor(i => i.Code) + .Matches("^[A-Z][A-Z0-9_]{1,49}$") + .When(i => !string.IsNullOrEmpty(i.Code)) + .WithMessage("Codes are upper-case letters, digits and underscores (2-50 chars)."); RuleFor(i => i.Name).NotEmpty(); + RuleFor(i => i.BusMessageTypeName) + .Matches("^\\S+$") + .When(i => !string.IsNullOrEmpty(i.BusMessageTypeName)) + .WithMessage("Bus message type name cannot contain spaces."); } } } diff --git a/SW.Bitween.Api/Resources/Documents/Delete.cs b/SW.Bitween.Api/Resources/Documents/Delete.cs index 2749201d..cddf1571 100644 --- a/SW.Bitween.Api/Resources/Documents/Delete.cs +++ b/SW.Bitween.Api/Resources/Documents/Delete.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Text; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Documents { @@ -22,7 +21,7 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext) async public Task Handle(int key) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Documents.Delete); await _dbContext.DeleteByKeyAsync(key); return null; diff --git a/SW.Bitween.Api/Resources/Documents/Get.cs b/SW.Bitween.Api/Resources/Documents/Get.cs index cbabd883..3b0a9885 100644 --- a/SW.Bitween.Api/Resources/Documents/Get.cs +++ b/SW.Bitween.Api/Resources/Documents/Get.cs @@ -14,17 +14,22 @@ namespace SW.Bitween.Resources.Documents public class Get : IGetHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(int key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Documents.View); + return await dbContext.Set().Search("Id", key).Select(document => new DocumentUpdate { Id = document.Id, + Code = document.Code, Name = document.Name, BusEnabled = document.BusEnabled, BusMessageTypeName = document.BusMessageTypeName, diff --git a/SW.Bitween.Api/Resources/Documents/GetProperties.cs b/SW.Bitween.Api/Resources/Documents/GetProperties.cs index afc108fd..acb65f24 100644 --- a/SW.Bitween.Api/Resources/Documents/GetProperties.cs +++ b/SW.Bitween.Api/Resources/Documents/GetProperties.cs @@ -12,14 +12,18 @@ namespace SW.Bitween.Resources.Documents public class GetProperties : IGetHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public GetProperties(BitweenDbContext dbContext) + public GetProperties(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } async public Task Handle(int key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Documents.View); + var document = await dbContext.FindAsync(key); return document.PromotedProperties.ToDictionary(k => k.Key, v => v.Key); } diff --git a/SW.Bitween.Api/Resources/Documents/GetTrail.cs b/SW.Bitween.Api/Resources/Documents/GetTrail.cs index 19b02d8e..63cd6932 100644 --- a/SW.Bitween.Api/Resources/Documents/GetTrail.cs +++ b/SW.Bitween.Api/Resources/Documents/GetTrail.cs @@ -14,15 +14,19 @@ namespace SW.Bitween.Resources.Documents; public class GetTrail : IQueryHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public GetTrail(BitweenDbContext dbContext) + public GetTrail(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(SearchDocumentTrailModel request) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Documents.View); + request.Limit ??= 20; request.Offset ??= 0; var trails = dbContext.Set() diff --git a/SW.Bitween.Api/Resources/Documents/Search.cs b/SW.Bitween.Api/Resources/Documents/Search.cs index 77823590..1b2938f4 100644 --- a/SW.Bitween.Api/Resources/Documents/Search.cs +++ b/SW.Bitween.Api/Resources/Documents/Search.cs @@ -14,19 +14,27 @@ namespace SW.Bitween.Resources.Documents public class Search : ISearchyHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } async public Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await requestContext.EnsurePermission(dbContext, Model.Permissions.Documents.View); + var query = from document in dbContext.Set() select new DocumentRow { Id = document.Id, + Code = document.Code, Name = document.Name, BusMessageTypeName = document.BusMessageTypeName, BusEnabled = document.BusEnabled, diff --git a/SW.Bitween.Api/Resources/Documents/Update.cs b/SW.Bitween.Api/Resources/Documents/Update.cs index c899d93c..a76b7522 100644 --- a/SW.Bitween.Api/Resources/Documents/Update.cs +++ b/SW.Bitween.Api/Resources/Documents/Update.cs @@ -5,7 +5,6 @@ using SW.PrimitiveTypes; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; -using SW.Bitween.Domain.Accounts; using System.Text.RegularExpressions; namespace SW.Bitween.Resources.Documents @@ -29,10 +28,40 @@ public Update(BitweenDbContext dbContext, IInfolinkCache BitweenCache, RequestCo public async Task Handle(int key, DocumentUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Documents.Edit); var entity = await _dbContext.FindAsync(key); + if (string.IsNullOrWhiteSpace(model.Name)) + throw new SWValidationException("INVALID_NAME", "Give the information type a name."); + + var nameDuplicated = await _dbContext.Set() + .AsNoTracking() + .Where(i => i.Id != key) + .AnyAsync(i => i.Name == model.Name); + if (nameDuplicated) + throw new SWValidationException("NAME_TAKEN", "An information type with this name already exists."); + + var code = string.IsNullOrWhiteSpace(model.Code) ? null : model.Code; + + if (code != null && !Regex.IsMatch(code, "^[A-Z][A-Z0-9_]{1,49}$")) + throw new SWValidationException("INVALID_CODE", + "Codes are upper-case letters, digits and underscores (2-50 chars)."); + + if (code != null) + { + var codeDuplicated = await _dbContext.Set() + .AsNoTracking() + .Where(i => i.Id != key) + .AnyAsync(i => i.Code == code); + if (codeDuplicated) + throw new SWValidationException("CODE_TAKEN", "This code is already in use."); + } + + if (!string.IsNullOrEmpty(model.BusMessageTypeName) && Regex.IsMatch(model.BusMessageTypeName, @"\s")) + throw new SWValidationException("INVALID_BUS_TYPE_NAME", + "Bus message type name cannot contain spaces."); + var busTypeNameDuplicated = await _dbContext.Set() .AsNoTracking() .Where(i => i.Id != key) @@ -85,6 +114,10 @@ public async Task Handle(int key, DocumentUpdate model) var trail = new DocumentTrail(DocumentTrailCode.Updated, entity); entity.SetDictionaries(model.PromotedProperties.ToDictionary()); + // Name/Code have private setters — SetProperties only writes public-setter + // properties, so it silently no-ops on these two (verified empirically). + entity.SetName(model.Name); + entity.SetCode(code); _dbContext.Entry(entity).SetProperties(model); trail.SetAfter(entity); diff --git a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Create.cs b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Create.cs index 6d131146..551bc716 100644 --- a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Create.cs +++ b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Create.cs @@ -2,7 +2,6 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -21,7 +20,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(GlobalAdapterValuesSetCreate request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.GlobalValues.Create); var exists = await _dbContext.Set().AnyAsync(x => x.Id == request.Id); if (exists) diff --git a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Delete.cs b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Delete.cs index 6a8e557a..40a20d97 100644 --- a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Delete.cs +++ b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Delete.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -20,7 +19,7 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(string key, DeleteGlobalAdapterValuesSetModel _) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.GlobalValues.Delete); var entity = await _dbContext.Set().FindAsync(key); if (entity is null) diff --git a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Get.cs b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Get.cs index 57a229e1..186f516f 100644 --- a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Get.cs +++ b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Get.cs @@ -19,7 +19,7 @@ public Get(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(string key) { - _requestContext.EnsureAccess(Domain.Accounts.AccountRole.Admin, Domain.Accounts.AccountRole.Member, Domain.Accounts.AccountRole.Viewer); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.GlobalValues.View); var entity = await _dbContext.Set() .AsNoTracking() diff --git a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Search.cs b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Search.cs index 9f3d1f73..15771b15 100644 --- a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Search.cs +++ b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Search.cs @@ -11,14 +11,21 @@ namespace SW.Bitween.Resources.GlobalAdapterValuesSets public class Search : ISearchyHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.GlobalValues.View); + var query = from item in _dbContext.Set() select new GlobalAdapterValuesSetRow { diff --git a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Update.cs b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Update.cs index 43090d1b..46377ad0 100644 --- a/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Update.cs +++ b/SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Update.cs @@ -1,7 +1,6 @@ using System.Threading.Tasks; using FluentValidation; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -20,7 +19,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(string key, GlobalAdapterValuesSetUpdate request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.GlobalValues.Edit); var entity = await _dbContext.Set().FindAsync(key); if (entity is null) diff --git a/SW.Bitween.Api/Resources/Login/Login.cs b/SW.Bitween.Api/Resources/Login/Login.cs index b5aa25ea..b823592a 100644 --- a/SW.Bitween.Api/Resources/Login/Login.cs +++ b/SW.Bitween.Api/Resources/Login/Login.cs @@ -31,9 +31,13 @@ public Task Handle(UserLogin request) { if (cred[1].Equals(request.Password)) { + // No account backs these configured credentials, so there are no roles to + // resolve — grant everything explicitly rather than relying on a guard that + // fails open on a missing claim. var claims = new List { new Claim(ClaimTypes.Name, cred[0]), + new Claim(RequestContextExtensions.SuperuserClaim, "true"), }; return Task.FromResult(new diff --git a/SW.Bitween.Api/Resources/Notifications/Search.cs b/SW.Bitween.Api/Resources/Notifications/Search.cs index cba67ac1..86052aff 100644 --- a/SW.Bitween.Api/Resources/Notifications/Search.cs +++ b/SW.Bitween.Api/Resources/Notifications/Search.cs @@ -11,14 +11,21 @@ namespace SW.Bitween.Resources.Notifications public class Search:ISearchyHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await requestContext.EnsurePermission(dbContext, Model.Permissions.Notifiers.View); + var query = from notification in dbContext.Set() select new NotificationsSearch() { diff --git a/SW.Bitween.Api/Resources/Notifiers/Create.cs b/SW.Bitween.Api/Resources/Notifiers/Create.cs index d117784d..bc6bb088 100644 --- a/SW.Bitween.Api/Resources/Notifiers/Create.cs +++ b/SW.Bitween.Api/Resources/Notifiers/Create.cs @@ -1,7 +1,6 @@ using System.Threading.Tasks; using FluentValidation; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -20,7 +19,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(NotifierCreate request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Notifiers.Create); var notifier = new Notifier(request.Name); diff --git a/SW.Bitween.Api/Resources/Notifiers/Get.cs b/SW.Bitween.Api/Resources/Notifiers/Get.cs index f675ed34..6f53ad20 100644 --- a/SW.Bitween.Api/Resources/Notifiers/Get.cs +++ b/SW.Bitween.Api/Resources/Notifiers/Get.cs @@ -11,14 +11,18 @@ namespace SW.Bitween.Resources.Notifiers public class Get: IGetHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(int key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Notifiers.View); + var notifier = await dbContext.Set().FirstOrDefaultAsync(n => n.Id == key); if (notifier == null) throw new SWNotFoundException(); diff --git a/SW.Bitween.Api/Resources/Notifiers/Search.cs b/SW.Bitween.Api/Resources/Notifiers/Search.cs index 7752ceac..d1171ec4 100644 --- a/SW.Bitween.Api/Resources/Notifiers/Search.cs +++ b/SW.Bitween.Api/Resources/Notifiers/Search.cs @@ -11,14 +11,21 @@ namespace SW.Bitween.Resources.Notifiers public class Search: ISearchyHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await requestContext.EnsurePermission(dbContext, Model.Permissions.Notifiers.View); + var query = from notifier in dbContext.Set() select new NotifierSearch() { diff --git a/SW.Bitween.Api/Resources/Notifiers/Update.cs b/SW.Bitween.Api/Resources/Notifiers/Update.cs index 44cf96af..690bbbd4 100644 --- a/SW.Bitween.Api/Resources/Notifiers/Update.cs +++ b/SW.Bitween.Api/Resources/Notifiers/Update.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using FluentValidation; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -21,7 +20,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key, NotifierUpdate request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Notifiers.Edit); var notifier = await _dbContext.FindAsync(key); diff --git a/SW.Bitween.Api/Resources/Ops/Alerts.cs b/SW.Bitween.Api/Resources/Ops/Alerts.cs index 417cea4d..f48ba65f 100644 --- a/SW.Bitween.Api/Resources/Ops/Alerts.cs +++ b/SW.Bitween.Api/Resources/Ops/Alerts.cs @@ -1,14 +1,18 @@ using System.Threading.Tasks; +using SW.Bitween.Domain; using SW.Bus.RabbitMqExtensions; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Ops; [HandlerName("Alerts")] -public class Alerts(IBusDashboardDataService dashboardDataService) : IQueryHandler +public class Alerts(IBusDashboardDataService dashboardDataService, + BitweenDbContext dbContext, RequestContext requestContext) : IQueryHandler { public async Task Handle() { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Monitoring.View, Model.Permissions.Dashboard.View); + return await dashboardDataService.GetAlertsAsync(); } } diff --git a/SW.Bitween.Api/Resources/Ops/Consumers.cs b/SW.Bitween.Api/Resources/Ops/Consumers.cs index 72b9a4cf..a2593ebb 100644 --- a/SW.Bitween.Api/Resources/Ops/Consumers.cs +++ b/SW.Bitween.Api/Resources/Ops/Consumers.cs @@ -1,14 +1,18 @@ using System.Threading.Tasks; +using SW.Bitween.Domain; using SW.Bus.RabbitMqExtensions; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Ops; [HandlerName("Consumers")] -public class Consumers(IBusDashboardDataService dashboardDataService) : IQueryHandler +public class Consumers(IBusDashboardDataService dashboardDataService, + BitweenDbContext dbContext, RequestContext requestContext) : IQueryHandler { public async Task Handle() { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Monitoring.View, Model.Permissions.Dashboard.View); + return await dashboardDataService.GetConsumerHealthAsync(); } } diff --git a/SW.Bitween.Api/Resources/Ops/DeadLetters.cs b/SW.Bitween.Api/Resources/Ops/DeadLetters.cs index 39a790ce..f9c0d183 100644 --- a/SW.Bitween.Api/Resources/Ops/DeadLetters.cs +++ b/SW.Bitween.Api/Resources/Ops/DeadLetters.cs @@ -1,14 +1,18 @@ using System.Threading.Tasks; +using SW.Bitween.Domain; using SW.Bus.RabbitMqExtensions; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Ops; [HandlerName("DeadLetters")] -public class DeadLetters(IBusDashboardDataService dashboardDataService) : IQueryHandler +public class DeadLetters(IBusDashboardDataService dashboardDataService, + BitweenDbContext dbContext, RequestContext requestContext) : IQueryHandler { public async Task Handle() { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Monitoring.View, Model.Permissions.Dashboard.View); + return await dashboardDataService.GetDeadLetterSummaryAsync(); } } diff --git a/SW.Bitween.Api/Resources/Ops/Queues.cs b/SW.Bitween.Api/Resources/Ops/Queues.cs index 6ebb2f34..a12cb4ae 100644 --- a/SW.Bitween.Api/Resources/Ops/Queues.cs +++ b/SW.Bitween.Api/Resources/Ops/Queues.cs @@ -1,14 +1,18 @@ using System.Threading.Tasks; +using SW.Bitween.Domain; using SW.Bus.RabbitMqExtensions; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Ops; [HandlerName("Queues")] -public class Queues(IBusDashboardDataService dashboardDataService) : IQueryHandler +public class Queues(IBusDashboardDataService dashboardDataService, + BitweenDbContext dbContext, RequestContext requestContext) : IQueryHandler { public async Task Handle() { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Monitoring.View, Model.Permissions.Dashboard.View); + return await dashboardDataService.GetQueueDetailsAsync(); } } diff --git a/SW.Bitween.Api/Resources/Ops/Retries.cs b/SW.Bitween.Api/Resources/Ops/Retries.cs index 2f08bd75..0300d67d 100644 --- a/SW.Bitween.Api/Resources/Ops/Retries.cs +++ b/SW.Bitween.Api/Resources/Ops/Retries.cs @@ -1,14 +1,18 @@ using System.Threading.Tasks; +using SW.Bitween.Domain; using SW.Bus.RabbitMqExtensions; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Ops; [HandlerName("Retries")] -public class Retries(IBusDashboardDataService dashboardDataService) : IQueryHandler +public class Retries(IBusDashboardDataService dashboardDataService, + BitweenDbContext dbContext, RequestContext requestContext) : IQueryHandler { public async Task Handle() { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Monitoring.View, Model.Permissions.Dashboard.View); + return await dashboardDataService.GetRetryAnalysisAsync(); } } diff --git a/SW.Bitween.Api/Resources/Ops/Summary.cs b/SW.Bitween.Api/Resources/Ops/Summary.cs index ae4a30bb..538291c1 100644 --- a/SW.Bitween.Api/Resources/Ops/Summary.cs +++ b/SW.Bitween.Api/Resources/Ops/Summary.cs @@ -1,14 +1,18 @@ using System.Threading.Tasks; +using SW.Bitween.Domain; using SW.Bus.RabbitMqExtensions; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Ops; [HandlerName("Summary")] -public class Summary(IBusDashboardDataService dashboardDataService) : IQueryHandler +public class Summary(IBusDashboardDataService dashboardDataService, + BitweenDbContext dbContext, RequestContext requestContext) : IQueryHandler { public async Task Handle() { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Monitoring.View, Model.Permissions.Dashboard.View); + return await dashboardDataService.GetSummaryAsync(); } } diff --git a/SW.Bitween.Api/Resources/Partners/Create.cs b/SW.Bitween.Api/Resources/Partners/Create.cs index 00f3f1d6..e1513051 100644 --- a/SW.Bitween.Api/Resources/Partners/Create.cs +++ b/SW.Bitween.Api/Resources/Partners/Create.cs @@ -2,7 +2,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Partners { @@ -19,7 +18,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(PartnerCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Partners.Create); var entity = new Partner(model.Name); _dbContext.Add(entity); diff --git a/SW.Bitween.Api/Resources/Partners/Delete.cs b/SW.Bitween.Api/Resources/Partners/Delete.cs index 16cc960e..1bb7486a 100644 --- a/SW.Bitween.Api/Resources/Partners/Delete.cs +++ b/SW.Bitween.Api/Resources/Partners/Delete.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Text; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Partners { @@ -23,7 +22,7 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Partners.Delete); if (key == Partner.SystemId) throw new SWException("System partner can not be deleted."); diff --git a/SW.Bitween.Api/Resources/Partners/Get.cs b/SW.Bitween.Api/Resources/Partners/Get.cs index bf96629b..e6cbb299 100644 --- a/SW.Bitween.Api/Resources/Partners/Get.cs +++ b/SW.Bitween.Api/Resources/Partners/Get.cs @@ -11,14 +11,18 @@ namespace SW.Bitween.Resources.Partners public class Get : IGetHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } async public Task Handle(int key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Partners.View); + return await dbContext.Set().AsNoTracking(). Search("Id", key). Select(partner => new PartnerUpdate diff --git a/SW.Bitween.Api/Resources/Partners/Search.cs b/SW.Bitween.Api/Resources/Partners/Search.cs index e43cc81d..60f7a037 100644 --- a/SW.Bitween.Api/Resources/Partners/Search.cs +++ b/SW.Bitween.Api/Resources/Partners/Search.cs @@ -4,28 +4,45 @@ using System.Linq; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; +using System.Collections.Generic; namespace SW.Bitween.Resources.Partners { public class Search : ISearchyHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } async public Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await requestContext.EnsurePermission(dbContext, Model.Permissions.Partners.View); + var query = from subscriber in dbContext.Set() select new PartnerRow { Id = subscriber.Id, Name = subscriber.Name, - SubscriptionsCount = subscriber.Subscriptions.Count, + // Every place the partner is wired in, matching the three groups the + // detail page lists. Counting only the direct subscription link left + // "Used by" reading "—" for partners plainly in use through a gateway. + SubscriptionsCount = subscriber.Subscriptions.Count + + dbContext.Set().Count(g => g.PartnerId == subscriber.Id) + + dbContext.Set().Count(r => r.PartnerId == subscriber.Id), Keys = subscriber.ApiCredentials.Count, + // Selected so the key names can be lifted out below. AdapterProperties + // is a JSON column, so picking its keys in SQL would be provider-specific. + AdapterProperties = subscriber.AdapterProperties, }; query = query.AsNoTracking(); @@ -35,10 +52,19 @@ async public Task Handle(SearchyRequest searchyRequest, bool lookup = fa return await query.Search(searchyRequest.Conditions).ToDictionaryAsync(k => k.Id.ToString(), v => v.Name); } + var result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(); + + foreach (var row in result) + { + row.PropertyKeys = row.AdapterProperties?.Keys.ToList() ?? new List(); + // Values can be secrets, so the list ships the names and nothing else. + row.AdapterProperties = null; + } + return new SearchyResponse { TotalCount = await query.Search(searchyRequest.Conditions).CountAsync(), - Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync() + Result = result }; } diff --git a/SW.Bitween.Api/Resources/Partners/Update.cs b/SW.Bitween.Api/Resources/Partners/Update.cs index 52d6601d..e18bfe76 100644 --- a/SW.Bitween.Api/Resources/Partners/Update.cs +++ b/SW.Bitween.Api/Resources/Partners/Update.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Partners { @@ -25,7 +24,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key, PartnerUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Partners.Edit); var entity = await _dbContext.FindAsync(key); entity.SetApiCredentials(model.ApiCredentials.Select(kv => new ApiCredential(kv.Key, kv.Value))); diff --git a/SW.Bitween.Api/Resources/Permissions/Get.cs b/SW.Bitween.Api/Resources/Permissions/Get.cs new file mode 100644 index 00000000..da2652f4 --- /dev/null +++ b/SW.Bitween.Api/Resources/Permissions/Get.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Permissions; + +/// +/// The permission catalog. Static data — the management UI builds its role matrix from this, so +/// the grants it offers can't drift from what the handlers actually enforce. +/// +public class Get : IQueryHandler +{ + public Task Handle() => Task.FromResult(PermissionCatalog.Areas); +} diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs index 4cb049f3..1c2ee391 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -19,7 +18,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(RetryPolicyCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.RetryPolicies.Create); var entity = new RetryPolicy { diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs index 571835e4..ba06bfdc 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.EfCoreExtensions; using SW.PrimitiveTypes; @@ -21,7 +20,7 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.RetryPolicies.Delete); var inUse = await _dbContext.Set() .AnyAsync(s => s.RetryPolicyId == key); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Get.cs b/SW.Bitween.Api/Resources/RetryPolicies/Get.cs index 7241dc80..6b5b866a 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Get.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Get.cs @@ -11,14 +11,18 @@ namespace SW.Bitween.Resources.RetryPolicies; public class Get : IGetHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(int key) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.RetryPolicies.View); + return await _dbContext.Set() .AsNoTracking() .Search("Id", key) diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Search.cs b/SW.Bitween.Api/Resources/RetryPolicies/Search.cs index c41261f6..39ab5e7b 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Search.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Search.cs @@ -11,14 +11,21 @@ namespace SW.Bitween.Resources.RetryPolicies; public class Search : ISearchyHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.RetryPolicies.View); + var query = from policy in _dbContext.Set() select new RetryPolicyRow { diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Test.cs b/SW.Bitween.Api/Resources/RetryPolicies/Test.cs index 8fbf12ec..e5a6f0a0 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Test.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Test.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -14,16 +13,19 @@ namespace SW.Bitween.Resources.RetryPolicies; [HandlerName("test")] public class Test : ICommandHandler { + private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; - public Test(RequestContext requestContext) + public Test(BitweenDbContext dbContext, RequestContext requestContext) { + _dbContext = dbContext; _requestContext = requestContext; } - public Task Handle(TestRetryPolicyRequest request) + public async Task Handle(TestRetryPolicyRequest request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + // A pure simulation with no side effects, so viewing a policy is enough to dry-run one. + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.RetryPolicies.View); if (request.ResultType == XchangeResultType.Success) throw new SWValidationException("INVALID_RESULT_TYPE", @@ -53,6 +55,6 @@ public Task Handle(TestRetryPolicyRequest request) if (!decision.ShouldRetry) break; } - return Task.FromResult(new TestRetryPolicyResponse { Attempts = attempts }); + return new TestRetryPolicyResponse { Attempts = attempts }; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs index 5d815fd4..64eafbac 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -19,7 +18,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key, RetryPolicyUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.RetryPolicies.Edit); var entity = await _dbContext.FindAsync(key); entity.Name = model.Name; diff --git a/SW.Bitween.Api/Resources/Roles/Create.cs b/SW.Bitween.Api/Resources/Roles/Create.cs new file mode 100644 index 00000000..a159ad66 --- /dev/null +++ b/SW.Bitween.Api/Resources/Roles/Create.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using FluentValidation; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Roles; + +public class Create : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Create(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(RoleCreate model) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Roles.Create); + + RoleValidation.EnsureKnownPermissions(model.Permissions); + await RoleValidation.EnsureNameIsFree(_dbContext, model.Name); + + var role = new Role(model.Name, model.Description, model.Permissions); + _dbContext.Add(role); + await _dbContext.SaveChangesAsync(); + return role.Id; + } + + private class Validate : AbstractValidator + { + public Validate() + { + RuleFor(i => i.Name).NotEmpty().MaximumLength(100); + RuleFor(i => i.Description).MaximumLength(500); + } + } +} diff --git a/SW.Bitween.Api/Resources/Roles/Delete.cs b/SW.Bitween.Api/Resources/Roles/Delete.cs new file mode 100644 index 00000000..b8520454 --- /dev/null +++ b/SW.Bitween.Api/Resources/Roles/Delete.cs @@ -0,0 +1,41 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Roles; + +public class Delete : IDeleteHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Delete(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Roles.Delete); + + var role = await RoleValidation.Load(_dbContext, key); + + if (role.IsSystem) + throw new SWValidationException("ROLE_IS_BUILT_IN", + $"'{role.Name}' is a built-in role and can't be deleted."); + + var memberCount = await _dbContext.Set().CountAsync(l => l.RoleId == key); + if (memberCount > 0) + throw new SWValidationException("ROLE_IN_USE", + $"'{role.Name}' is still assigned to {memberCount} member{(memberCount == 1 ? "" : "s")}. " + + "Move them to another role first."); + + _dbContext.Remove(role); + await _dbContext.SaveChangesAsync(); + return null; + } +} diff --git a/SW.Bitween.Api/Resources/Roles/Get.cs b/SW.Bitween.Api/Resources/Roles/Get.cs new file mode 100644 index 00000000..0f7b4fed --- /dev/null +++ b/SW.Bitween.Api/Resources/Roles/Get.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Roles; + +public class Get : IGetHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Get(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Roles.View); + + var row = await _dbContext.Set() + .AsNoTracking() + .Where(role => role.Id == key) + .Select(role => new RoleRow + { + Id = role.Id, + Name = role.Name, + Description = role.Description, + IsSystem = role.IsSystem, + Permissions = role.Permissions, + CreatedOn = role.CreatedOn, + MemberCount = _dbContext.Set().Count(l => l.RoleId == role.Id) + }) + .SingleOrDefaultAsync(); + + if (row is not null && row.IsSystem) + row.Permissions = Role.SystemPermissions(row.Id); + + return row; + } +} diff --git a/SW.Bitween.Api/Resources/Roles/RoleValidation.cs b/SW.Bitween.Api/Resources/Roles/RoleValidation.cs new file mode 100644 index 00000000..1e872a86 --- /dev/null +++ b/SW.Bitween.Api/Resources/Roles/RoleValidation.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Roles; + +internal static class RoleValidation +{ + /// + /// Rejects keys the catalog doesn't define. The entity also sanitizes, but failing loudly at + /// the boundary surfaces a stale UI instead of silently dropping the grants it asked for. + /// + public static void EnsureKnownPermissions(List permissions) + { + var unknown = (permissions ?? []) + .Where(k => !PermissionCatalog.AllKeys.Contains(k)) + .Distinct() + .ToList(); + + if (unknown.Count > 0) + throw new SWValidationException("UNKNOWN_PERMISSIONS", + $"These permissions don't exist: {string.Join(", ", unknown)}."); + } + + public static async Task EnsureNameIsFree(BitweenDbContext dbContext, string name, int? exceptId = null) + { + var taken = await dbContext.Set() + .AnyAsync(r => r.Name == name && (exceptId == null || r.Id != exceptId)); + + if (taken) + throw new SWValidationException("ROLE_EXISTS", $"A role named '{name}' already exists."); + } + + public static async Task Load(BitweenDbContext dbContext, int key) + { + var role = await dbContext.Set().FindAsync(key); + if (role is null) + throw new SWValidationException("ROLE_NOT_FOUND", $"No role exists with the id {key}."); + return role; + } +} diff --git a/SW.Bitween.Api/Resources/Roles/Search.cs b/SW.Bitween.Api/Resources/Roles/Search.cs new file mode 100644 index 00000000..0ec1f919 --- /dev/null +++ b/SW.Bitween.Api/Resources/Roles/Search.cs @@ -0,0 +1,57 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.EfCoreExtensions; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Roles; + +public class Search : ISearchyHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Search(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Roles.View); + + var query = from role in _dbContext.Set() + select new RoleRow + { + Id = role.Id, + Name = role.Name, + Description = role.Description, + IsSystem = role.IsSystem, + Permissions = role.Permissions, + CreatedOn = role.CreatedOn, + MemberCount = _dbContext.Set().Count(l => l.RoleId == role.Id) + }; + + query = query.AsNoTracking(); + + if (lookup) + return await query.Search(searchyRequest.Conditions) + .ToDictionaryAsync(k => k.Id.ToString(), v => v.Name); + + var result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, + searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(); + + // Built-in roles derive their grants from the catalog, so fill them in after the query. + foreach (var row in result.Where(r => r.IsSystem)) + row.Permissions = Role.SystemPermissions(row.Id); + + return new SearchyResponse + { + TotalCount = await query.Search(searchyRequest.Conditions).CountAsync(), + Result = result + }; + } +} diff --git a/SW.Bitween.Api/Resources/Roles/Update.cs b/SW.Bitween.Api/Resources/Roles/Update.cs new file mode 100644 index 00000000..5667f887 --- /dev/null +++ b/SW.Bitween.Api/Resources/Roles/Update.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using FluentValidation; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Roles; + +public class Update : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Update(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, RoleUpdate model) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Roles.Edit); + + var role = await RoleValidation.Load(_dbContext, key); + + // Built-in roles are the floor an instance can always fall back to — if Administrator + // could be edited, an admin could lock everyone out of members and roles for good. + if (role.IsSystem) + throw new SWValidationException("ROLE_IS_BUILT_IN", + $"'{role.Name}' is a built-in role and can't be changed. Create a role instead."); + + RoleValidation.EnsureKnownPermissions(model.Permissions); + await RoleValidation.EnsureNameIsFree(_dbContext, model.Name, key); + + role.Update(model.Name, model.Description, model.Permissions); + await _dbContext.SaveChangesAsync(); + return null; + } + + private class Validate : AbstractValidator + { + public Validate() + { + RuleFor(i => i.Name).NotEmpty().MaximumLength(100); + RuleFor(i => i.Description).MaximumLength(500); + } + } +} diff --git a/SW.Bitween.Api/Resources/Settings/Config.cs b/SW.Bitween.Api/Resources/Settings/Config.cs index c6a95b5d..c8603170 100644 --- a/SW.Bitween.Api/Resources/Settings/Config.cs +++ b/SW.Bitween.Api/Resources/Settings/Config.cs @@ -27,7 +27,10 @@ public async Task Handle() IsRabbitMqManagementConfigured = !string.IsNullOrWhiteSpace(_BitweenOptions.RabbitMqManagementUrl) && !string.IsNullOrWhiteSpace(_BitweenOptions.RabbitMqManagementUsername) && !string.IsNullOrWhiteSpace(_BitweenOptions.RabbitMqManagementPassword), - Theme = _themeOptions + Theme = _themeOptions, + // The product defaults, so the sign-in page — which has no session and can't read the + // settings list — can tell a brand value someone chose from one nobody has touched. + ThemeDefaults = SettingsService.DefaultsUnder("Theme.") }; } } diff --git a/SW.Bitween.Api/Resources/Settings/Delete.cs b/SW.Bitween.Api/Resources/Settings/Delete.cs new file mode 100644 index 00000000..c78f5cde --- /dev/null +++ b/SW.Bitween.Api/Resources/Settings/Delete.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; +using SW.Bitween.Domain; +using SW.Bitween.Services; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Settings; + +/// +/// Resets one setting to the product default — the value the options class ships with. +/// +/// The row is rewritten rather than deleted: a missing row is how startup recognises a key it has +/// never imported, so dropping it would let configuration seep back in on the next boot. Reset +/// therefore means "stop choosing", not "forget this key exists". +/// +/// +public class Delete( + BitweenDbContext dbContext, + RequestContext requestContext, + SettingsService settings, + IInfolinkCache cache, + IServiceProvider serviceProvider) : IDeleteHandler +{ + public async Task Handle(string key) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Settings.Edit); + + var definition = SettingsCatalog.Find(key) + ?? throw new SWValidationException("SETTING_NOT_FOUND", $"'{key}' is not a known setting."); + + if (!definition.Stored) + throw new SWValidationException("SETTING_NOT_EDITABLE", + $"{definition.Label} comes from this instance's configuration, so there's nothing to reset."); + + if (!settings.CanStore(definition)) + throw new SWValidationException("SETTING_ENCRYPTION_UNAVAILABLE", + $"{definition.Label} is a secret that isn't stored, so there's nothing to reset."); + + var productDefault = SettingsService.DefaultOf(definition); + var stored = await dbContext.Set().FindAsync(definition.Key); + var toStore = settings.ToStored(definition, productDefault); + + if (stored is null) + dbContext.Add(new Setting { Id = definition.Key, Value = toStore }); + else + stored.Value = toStore; + + await dbContext.SaveChangesAsync(); + + // Resetting an already-default setting is a no-op, not an error — the UI can fire this for + // a staged reset it never saved a value for. + settings.Apply(definition, productDefault); + + // A reset has to take effect the same way a write does — see Update. + if (definition.OnChange is not null) await definition.OnChange(serviceProvider); + + await cache.BroadcastRevoke(); + + return null; + } +} diff --git a/SW.Bitween.Api/Resources/Settings/Get.cs b/SW.Bitween.Api/Resources/Settings/Get.cs new file mode 100644 index 00000000..84614464 --- /dev/null +++ b/SW.Bitween.Api/Resources/Settings/Get.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.Bitween.Services; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Settings; + +/// +/// Every editable setting, in catalog order: the definition from +/// joined with the stored value. Rows normally exist for all of them — startup imports whatever +/// configuration had — so a missing row means the key couldn't be stored yet. +/// +public class Get(BitweenDbContext dbContext, RequestContext requestContext, SettingsService settings) + : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Settings.View); + + var rows = await dbContext.Set().AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Value, StringComparer.OrdinalIgnoreCase); + + return SettingsCatalog.All.Select(definition => + { + // Environment-owned settings have no row to join to: they're reported straight from + // the options object, either with their value or as set/not set. + if (!definition.Stored) return EnvironmentRow(definition, settings.LiveValue(definition)); + + var hasRow = rows.TryGetValue(definition.Key, out var stored); + var productDefault = SettingsService.DefaultOf(definition); + // A secret's value is withheld either way round: only whether one is set is public. + var value = definition.Secret ? null : hasRow ? stored : productDefault; + // A secret has no product default, and its ciphertext couldn't be compared with one + // anyway — so for a secret both "is set" and "is overridden" mean the same thing: + // a non-empty value is stored. + var secretIsSet = hasRow && !string.IsNullOrEmpty(stored); + + return new SettingRow + { + Key = definition.Key, + Section = definition.Section, + Label = definition.Label, + Description = definition.Description, + Kind = definition.Kind.ToString().ToLowerInvariant(), + DefaultValue = definition.Secret ? string.Empty : productDefault, + Value = value, + Secret = definition.Secret, + Overridden = definition.Secret ? secretIsSet : hasRow && stored != productDefault, + HasValue = definition.Secret + ? secretIsSet + : !string.IsNullOrEmpty(hasRow ? stored : productDefault), + // The only thing that makes a stored setting uneditable: a secret with no + // passphrase configured to protect it. + Editable = settings.CanStore(definition), + Access = Name(definition.Access) + }; + }).ToArray(); + } + + /// + /// A setting the UI reports but can't change. There's no default and nothing to reset, so + /// those stay empty; a presence setting withholds the value the same way a secret does. + /// + private static SettingRow EnvironmentRow(SettingDefinition definition, string live) => new() + { + Key = definition.Key, + Section = definition.Section, + Label = definition.Label, + Description = definition.Description, + Kind = definition.Kind.ToString().ToLowerInvariant(), + DefaultValue = string.Empty, + Value = definition.Access == SettingAccess.Presence ? null : live, + Secret = false, + Overridden = false, + HasValue = !string.IsNullOrEmpty(live), + Editable = false, + Access = Name(definition.Access) + }; + + private static string Name(SettingAccess access) => access.ToString().ToLowerInvariant(); +} diff --git a/SW.Bitween.Api/Resources/Settings/Update.cs b/SW.Bitween.Api/Resources/Settings/Update.cs new file mode 100644 index 00000000..f47a2f6f --- /dev/null +++ b/SW.Bitween.Api/Resources/Settings/Update.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading.Tasks; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.Bitween.Services; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Settings; + +/// +/// Stores a new value for one setting. It's applied to the live options singletons as well as +/// stored, so the very next request already sees it; the cache-revoke broadcast carries the change +/// to any other instance. Secrets are encrypted before they're written. Only editable settings can +/// be written at all — the environment-owned ones the page also lists are rejected here. +/// +public class Update( + BitweenDbContext dbContext, + RequestContext requestContext, + SettingsService settings, + IInfolinkCache cache, + IServiceProvider serviceProvider) : ICommandHandler +{ + public async Task Handle(string key, SettingUpdate request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Settings.Edit); + + var definition = SettingsCatalog.Find(key) + ?? throw new SWValidationException("SETTING_NOT_FOUND", $"'{key}' is not a known setting."); + + if (!definition.Stored) + throw new SWValidationException("SETTING_NOT_EDITABLE", + $"{definition.Label} comes from this instance's configuration and can't be changed here."); + + if (!settings.CanStore(definition)) + throw new SWValidationException("SETTING_ENCRYPTION_UNAVAILABLE", + $"{definition.Label} is a secret and can only be stored once " + + $"{BitweenOptions.ConfigurationSection}:{nameof(BitweenOptions.SettingsEncryptionKey)} is configured."); + + // Empty is a real value — it's how you clear an optional link or a license key. + var value = request?.Value ?? string.Empty; + + try + { + SettingsService.Validate(definition, value); + } + catch (FormatException ex) + { + throw new SWValidationException("SETTING_INVALID_VALUE", $"{definition.Label}: {ex.Message}"); + } + + await Store(definition, value); + settings.Apply(definition, value); + + // Some settings need more than the property assignment above — re-scheduling a job, for + // instance. Runs after Apply so the hook reads the value that's now in effect. + if (definition.OnChange is not null) await definition.OnChange(serviceProvider); + + await cache.BroadcastRevoke(); + + return null; + } + + /// + /// Writes the value, creating the row if startup hasn't imported this key yet. Every setting + /// keeps exactly one row, keyed by the catalog key. + /// + private async Task Store(SettingDefinition definition, string value) + { + var stored = await dbContext.Set().FindAsync(definition.Key); + var toStore = settings.ToStored(definition, value); + + if (stored is null) + dbContext.Add(new Setting { Id = definition.Key, Value = toStore }); + else + stored.Value = toStore; + + await dbContext.SaveChangesAsync(); + } +} diff --git a/SW.Bitween.Api/Resources/SubscriptionCategories/Search.cs b/SW.Bitween.Api/Resources/SubscriptionCategories/Search.cs index d532712e..f6e1ccd1 100644 --- a/SW.Bitween.Api/Resources/SubscriptionCategories/Search.cs +++ b/SW.Bitween.Api/Resources/SubscriptionCategories/Search.cs @@ -16,10 +16,13 @@ public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; _requestContext = requestContext; + _requestContext = requestContext; } public async Task Handle(SearchSubscriptionCategoryModel request) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); + request.Limit ??= 20; request.Offset ??= 0; var q = _dbContext.Set().AsNoTracking().AsQueryable(); diff --git a/SW.Bitween.Api/Resources/Subscriptions/AggregateNow.cs b/SW.Bitween.Api/Resources/Subscriptions/AggregateNow.cs index 653583c6..00c4cdbb 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/AggregateNow.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/AggregateNow.cs @@ -2,7 +2,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Subscriptions { @@ -22,7 +21,7 @@ public AggregateNow(BitweenDbContext dbContext, RequestContext requestContext, S public async Task Handle(int key, SubscriptionAggregateNow request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Operate); var entity = await _dbContext.FindAsync(key); entity.SetAggregateNow(); diff --git a/SW.Bitween.Api/Resources/Subscriptions/Create.cs b/SW.Bitween.Api/Resources/Subscriptions/Create.cs index 2597bfbf..6cc935f7 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Create.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Create.cs @@ -3,7 +3,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Subscriptions { @@ -20,7 +19,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(SubscriptionCreate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Create); Subscription entity; diff --git a/SW.Bitween.Api/Resources/Subscriptions/Delete.cs b/SW.Bitween.Api/Resources/Subscriptions/Delete.cs index dbb1c597..c4c31e92 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Delete.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Delete.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Text; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Subscriptions { @@ -23,7 +22,7 @@ public Delete(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Viewer); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Delete); await _dbContext.DeleteByKeyAsync(key); return null; diff --git a/SW.Bitween.Api/Resources/Subscriptions/Get.cs b/SW.Bitween.Api/Resources/Subscriptions/Get.cs index 120855e4..6d0e9614 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Get.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Get.cs @@ -14,20 +14,24 @@ namespace SW.Bitween.Resources.Subscriptions public class Get : IGetHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; private readonly IServiceProvider _serviceProvider; private const string PrivateSentinel = "__private__"; - public Get(BitweenDbContext dbContext, NativeAdapterDiscoveryService nativeAdapterDiscovery, IServiceProvider serviceProvider) + public Get(BitweenDbContext dbContext, NativeAdapterDiscoveryService nativeAdapterDiscovery, IServiceProvider serviceProvider, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; _nativeAdapterDiscovery = nativeAdapterDiscovery; _serviceProvider = serviceProvider; } public async Task Handle(int key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View); + var subscriber = await dbContext.Set().AsNoTracking().Search("Id", key).SingleOrDefaultAsync(); diff --git a/SW.Bitween.Api/Resources/Subscriptions/GetTrail.cs b/SW.Bitween.Api/Resources/Subscriptions/GetTrail.cs index 9894fd06..b9e6079c 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/GetTrail.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/GetTrail.cs @@ -14,15 +14,19 @@ namespace SW.Bitween.Resources.Subscriptions; public class GetTrail : IQueryHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public GetTrail(BitweenDbContext dbContext) + public GetTrail(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } public async Task Handle(SearchSubscriptionTrailModel request) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View); + request.Limit ??= 20; request.Offset ??= 0; var trails = dbContext.Set() diff --git a/SW.Bitween.Api/Resources/Subscriptions/Pause.cs b/SW.Bitween.Api/Resources/Subscriptions/Pause.cs index 44d14d67..c56317ff 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Pause.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Pause.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using Newtonsoft.Json; using SW.Bitween.Domain; -using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -23,7 +22,7 @@ public Pause(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key, SubscriptionPause request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Operate); var entity = await _dbContext.FindAsync(key); SubscriptionTrail trail; diff --git a/SW.Bitween.Api/Resources/Subscriptions/ReceiveNow.cs b/SW.Bitween.Api/Resources/Subscriptions/ReceiveNow.cs index 1dbcf31b..b6141b9f 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/ReceiveNow.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/ReceiveNow.cs @@ -2,7 +2,6 @@ using SW.Bitween.Model; using SW.PrimitiveTypes; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Subscriptions { @@ -22,7 +21,7 @@ public ReceiveNow(BitweenDbContext dbContext, RequestContext requestContext, Sub async public Task Handle(int key, SubscriptionReceiveNow request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Operate); var entity = await _dbContext.FindAsync(key); entity.SetReceiveNow(); diff --git a/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs b/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs index bacc50e6..7819dd5a 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs @@ -7,7 +7,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Subscriptions { @@ -27,7 +26,7 @@ public SaveMapper(BitweenDbContext dbContext, IInfolinkCache BitweenCache, Reque public async Task Handle(int key, SubscriptionSaveMapper model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Edit); var entity = await _dbContext.FindAsync(key); entity.MapperId = model.MapperId; diff --git a/SW.Bitween.Api/Resources/Subscriptions/Search.cs b/SW.Bitween.Api/Resources/Subscriptions/Search.cs index 4b6c9295..e87aaf00 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Search.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Search.cs @@ -15,11 +15,13 @@ namespace SW.Bitween.Resources.Subscriptions public class Search : ISearchyHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; private readonly List _edgeCaseProperties; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; _edgeCaseProperties = new List { @@ -32,6 +34,11 @@ public Search(BitweenDbContext dbContext) public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); + var query = from subscriber in _dbContext.Set() join document in _dbContext.Set() on subscriber.DocumentId equals document.Id select new SubscriptionSearch @@ -51,6 +58,9 @@ join document in _dbContext.Set() on subscriber.DocumentId equals docu ReceiveOn = subscriber.ReceiveOn, PausedOn = subscriber.PausedOn, IsRunning = subscriber.IsRunning, + ConsecutiveFailures = subscriber.ConsecutiveFailures, + LastException = subscriber.LastException, + RetryPolicyId = subscriber.RetryPolicyId, MapperProperties = subscriber.MapperProperties.ToKeyAndValueCollection(), HandlerProperties = subscriber.HandlerProperties.ToKeyAndValueCollection(), ReceiverProperties = subscriber.ReceiverProperties.ToKeyAndValueCollection(), @@ -62,7 +72,6 @@ join document in _dbContext.Set() on subscriber.DocumentId equals docu WorkGroupId = subscriber.WorkGroupId, CategoryDescription = subscriber.Category.Description, CategoryCode = subscriber.Category.Code, - RetryPolicyId = subscriber.RetryPolicyId, CustomRetryPolicy = subscriber.CustomRetryPolicy, }; diff --git a/SW.Bitween.Api/Resources/Subscriptions/Update.cs b/SW.Bitween.Api/Resources/Subscriptions/Update.cs index b93101b1..4fd0763c 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Update.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Update.cs @@ -10,7 +10,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using SW.Bitween.Domain.Accounts; namespace SW.Bitween.Resources.Subscriptions { @@ -33,7 +32,7 @@ public Update(BitweenDbContext dbContext, IInfolinkCache BitweenCache, RequestCo public async Task Handle(int key, SubscriptionUpdate model) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.Edit); var entity = await _dbContext.FindAsync(key); // Capture before SetSchedules replaces the collection. diff --git a/SW.Bitween.Api/Resources/WorkGroups/Create.cs b/SW.Bitween.Api/Resources/WorkGroups/Create.cs index f22e0e3a..931f2699 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Create.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Create.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; +using FluentValidation; using SW.Bitween.Domain; -using SW.Bitween.Model; +using SW.Bitween.Model; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.WorkGroups; @@ -12,6 +13,8 @@ public class Create(BitweenDbContext dbContext, RequestContext requestContext,II public async Task Handle(CreateWorkGroupModel request) { + await _requestContext.EnsurePermission(dbContext, Model.Permissions.WorkGroups.Create); + var workgroup = new WorkGroup() { Name = request.Name, @@ -34,4 +37,15 @@ public async Task Handle(CreateWorkGroupModel request) workgroup.Id }; } + + private class Validate : AbstractValidator + { + public Validate() + { + RuleFor(i => i.BusMessageName) + .Matches("^\\S+$") + .When(i => !string.IsNullOrEmpty(i.BusMessageName)) + .WithMessage("Bus message name cannot contain spaces."); + } + } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/WorkGroups/Delete.cs b/SW.Bitween.Api/Resources/WorkGroups/Delete.cs index 05b5dfed..23db025a 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Delete.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Delete.cs @@ -14,6 +14,8 @@ public class Delete(BitweenDbContext dbContext, RequestContext requestContext, I public async Task Handle(int key, DeleteWorkGroupModel _) { + await _requestContext.EnsurePermission(dbContext, Model.Permissions.WorkGroups.Delete); + var category = await dbContext.Set().FindAsync(key); if (category is null) throw new SWValidationException("CATEGORY_NOT_FOUND", $"Workgroup with id {key} was not found"); diff --git a/SW.Bitween.Api/Resources/WorkGroups/Search.cs b/SW.Bitween.Api/Resources/WorkGroups/Search.cs index 099b9c7a..8a9868ae 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Search.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Search.cs @@ -11,7 +11,8 @@ namespace SW.Bitween.Resources.WorkGroups; public class Search( - IInfolinkCache infolinkCache, + BitweenDbContext dbContext, + RequestContext requestContext, IConsumerReader consumerReader, ILogger logger) : IQueryHandler @@ -19,10 +20,19 @@ public class Search( public async Task Handle(SearchWorkGroupModel request) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.WorkGroups.View); + request.Limit ??= 20; request.Offset ??= 0; - - var workGroups = await infolinkCache.ListWorkGroupsAsync(); + + // Read straight from the DB rather than IInfolinkCache: that cache only + // invalidates via a broadcast back to itself over RabbitMQ (see + // WorkGroups/Create|Update|Delete.cs calling BroadcastRevoke()), so a + // freshly created/edited/deleted work group can stay invisible here + // until the cache's own TTL expires whenever that broadcast doesn't + // land. GlobalAdapterValuesSets and RetryPolicies already read the DB + // directly for the same reason. + var workGroups = await dbContext.Set().AsNoTracking().ToArrayAsync(); var consumerCounts = Array.Empty(); try @@ -65,6 +75,7 @@ public async Task Handle(SearchWorkGroupModel request) NotifierIncomingRate = notifiersCounts?.IncomingRate, NotifierProcessingCount = notifiersCounts?.ProcessingCount, NotifierQueueCount = notifiersCounts?.QueueCount, + ProcessorNodeCount = processorsCounts?.TotalNodes, }; }).ToList(); diff --git a/SW.Bitween.Api/Resources/WorkGroups/Update.cs b/SW.Bitween.Api/Resources/WorkGroups/Update.cs index e24ee6b0..7cb8f5d8 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Update.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Update.cs @@ -5,14 +5,23 @@ namespace SW.Bitween.Resources.WorkGroups; -public class Update(BitweenDbContext dbContext,IInfolinkCache _BitweenCache, IBroadcast _broadcast) : ICommandHandler +public class Update(BitweenDbContext dbContext, RequestContext requestContext, IInfolinkCache _BitweenCache, IBroadcast _broadcast) : ICommandHandler { + private readonly RequestContext _requestContext = requestContext; + public async Task Handle(int key, CreateWorkGroupModel request) { + await _requestContext.EnsurePermission(dbContext, Model.Permissions.WorkGroups.Edit); + var workGroup = await dbContext.Set().FindAsync(key); if (workGroup is null) throw new SWValidationException("WORK_GROUP_NOT_FOUND", $"Category with id {key} was not found"); + + // Update binds the same CreateWorkGroupModel type as Create, so Create's + // Validate (IValidator) already runs for this request too — + // CqApiController resolves validators by the request's concrete type. workGroup.Name = request.Name; + workGroup.BusMessageName = request.BusMessageName; workGroup.Options = new WorkGroupOptions { RabbitMqOptions = new ConsumerSettings diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs index 282549e5..2d2a356d 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs @@ -49,7 +49,7 @@ public async Task Handle(XchangeBulkRetry request) else { - await _xchangeService.CreateXchange(xchange, xchangeFile, subscription.WorkGroup); + await _xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup); } } diff --git a/SW.Bitween.Api/Resources/Xchanges/Create.cs b/SW.Bitween.Api/Resources/Xchanges/Create.cs index e54a9121..55208ede 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Create.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Create.cs @@ -22,7 +22,7 @@ public Create(XchangeService xchangeService, BitweenDbContext dbc) public async Task Handle(CreateXchange request) { - var xchangeFile = new XchangeFile(request.Data); + var xchangeFile = new XchangeFile(request.Data, "manual.json"); if (request.Option == CreateXchangeOption.DocumentId) { var document = await _dbc.Set().FirstOrDefaultAsync(d => d.Id == request.DocumentId); diff --git a/SW.Bitween.Api/Resources/Xchanges/GetInternal.cs b/SW.Bitween.Api/Resources/Xchanges/GetInternal.cs index edadf257..974dc7de 100644 --- a/SW.Bitween.Api/Resources/Xchanges/GetInternal.cs +++ b/SW.Bitween.Api/Resources/Xchanges/GetInternal.cs @@ -15,14 +15,18 @@ namespace SW.Bitween.Resources.Xchanges public class GetInternal : IGetHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public GetInternal(BitweenDbContext dbContext) + public GetInternal(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; } async public Task Handle(int key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Exchanges.View); + return await dbContext.Set().AsNoTracking(). Search("Id", key). Select( xchange => new XchangeRow diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index aef8e649..e096981e 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -15,16 +15,23 @@ namespace SW.Bitween.Resources.Xchanges public class Search : ISearchyHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; private readonly XchangeService xchangeService; - public Search(BitweenDbContext dbContext, XchangeService xchangeService) + public Search(BitweenDbContext dbContext, XchangeService xchangeService, RequestContext requestContext) { this.dbContext = dbContext; + this.requestContext = requestContext; this.xchangeService = xchangeService; } public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { + // Lookup returns only id/name pairs, which pickers across the app rely on; + // the full list is the data, so that's what the view permission covers. + if (!lookup) + await requestContext.EnsurePermission(dbContext, Model.Permissions.Exchanges.View, Model.Permissions.Dashboard.View); + searchyRequest.DatesToUtc(); await using var dr = await dbContext.Database.BeginTransactionAsync(IsolationLevel.ReadUncommitted); @@ -72,7 +79,12 @@ from delayedRetry in drGroup.DefaultIfEmpty() OutputFileName = result.OutputName, ResponseFileName = result.ResponseName, CorrelationId = xchange.CorrelationId, - PartnerId = subscriber.PartnerId, + // xchange.PartnerId is the authoritative source (set at creation from the + // gateway/bus-route partner, or the subscription's own PartnerId as a + // fallback there too) but the column was added later with no backfill, so + // pre-migration xchanges have it null even when their subscription carries + // a direct PartnerId — fall back to that for those legacy rows. + PartnerId = xchange.PartnerId ?? subscriber.PartnerId, ScheduledRetryOn = delayedRetry != null ? delayedRetry.On : (DateTime?)null }; diff --git a/SW.Bitween.Api/Resources/Xchanges/Update.cs b/SW.Bitween.Api/Resources/Xchanges/Update.cs index bb395ec1..b8191cdc 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Update.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Update.cs @@ -85,7 +85,9 @@ public async Task Handle(string documentIdOrName, dynamic request) var xchangeFile = new XchangeFile(request.ToString()); - await _xchangeService.RunValidator(sub.ValidatorId, sub.ValidatorProperties.ToDictionary(), xchangeFile); + var globalAdapterValuesSets = await _cache.ListGlobalAdapterValuesSetsAsync(); + var validatorProperties = sub.ValidatorProperties.ToDictionary().Fill(par.Partner, globalAdapterValuesSets); + await _xchangeService.RunValidator(sub.ValidatorId, validatorProperties, xchangeFile); var xchangeId = await _xchangeService.SubmitSubscriptionXchange(sub.Id, xchangeFile, xchangeReferences.ToArray()); diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index 5a6e99d7..cc59e78b 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -24,6 +24,9 @@ + + diff --git a/SW.Bitween.Api/Services/BitweenOptions.cs b/SW.Bitween.Api/Services/BitweenOptions.cs index a5b05aa3..a6b8da35 100644 --- a/SW.Bitween.Api/Services/BitweenOptions.cs +++ b/SW.Bitween.Api/Services/BitweenOptions.cs @@ -9,15 +9,13 @@ public class BitweenOptions public BitweenOptions() { // AESEncryptionKey = "BitweenS9SecretKey"; - AdapterPath = "./adapters"; + AdapterPath = "adapters"; AdminCredentials = "admin:1234512345"; DocumentPrefix = "temp30/Bitweendocs"; - ClientIpHeaderName = "X-Real-IP"; DatabaseType = "MySql"; AdminDatabaseName = "defaultdb"; ServerlessCommandTimeout = 300; ApiCallSubscriptionResponseAcceptedStatusCode = 202; - ReceiversDelayInSeconds = 63; StorageProvider = "S3"; JwtExpiryMinutes = 60; BusDefaultQueuePrefetch = 12; @@ -29,14 +27,17 @@ public BitweenOptions() public string DatabaseType { get; set; } public string AdminDatabaseName { get; set; } + + /// + /// Cloud-storage key prefix the serverless runner downloads custom adapter packages from + /// ({AdapterPath}/{adapterId}). Passed to ServerlessOptions.AdapterRemotePath. + /// public string AdapterPath { get; set; } public string AdminCredentials { get; set; } public string DocumentPrefix { get; set; } - public string ClientIpHeaderName { get; set; } public int ServerlessCommandTimeout { get; set; } public bool AreXChangeFilesPrivate { get; set; } = false; public int? ApiCallSubscriptionResponseAcceptedStatusCode { get; set; } - public int? ReceiversDelayInSeconds { get; set; } public string StorageProvider { get; set; } @@ -71,12 +72,22 @@ public BitweenOptions() /// public string AzureManagedIdentityClientId { get; set; } + /// + /// Passphrase used to encrypt secret settings before they're stored. Environment-only and + /// never itself a setting — it's what protects the table, so it can't live in it. Without + /// it, secret settings are neither imported nor editable and keep coming from configuration. + /// Rotating it makes anything already stored unreadable. + /// + public string SettingsEncryptionKey { get; set; } + public string RabbitMqManagementUrl { get; set; } public string RabbitMqManagementUsername { get; set; } public string RabbitMqManagementPassword { get; set; } /// - /// License key for the Rebex POP3 library. When not set, the native Rebex POP3 receiver adapter is not registered. + /// License key for the Rebex library the native POP3 and FTP adapters are built on. Those + /// adapters are always registered, so a key stored in Settings takes effect without a + /// restart; while no key is set they're kept out of the adapter pickers instead. /// public string RebexLicenseKey { get; set; } diff --git a/SW.Bitween.Api/Services/CacheRevokeService.cs b/SW.Bitween.Api/Services/CacheRevokeService.cs index 86e3b958..b499372e 100644 --- a/SW.Bitween.Api/Services/CacheRevokeService.cs +++ b/SW.Bitween.Api/Services/CacheRevokeService.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using SW.Bitween.Services; using SW.PrimitiveTypes; namespace SW.Bitween; @@ -6,15 +7,21 @@ namespace SW.Bitween; public class CacheRevokeService : IListen { private readonly IInfolinkCache _BitweenCache; + private readonly SettingsService _settings; + private readonly BitweenDbContext _dbContext; - public CacheRevokeService(IInfolinkCache BitweenCache) + public CacheRevokeService(IInfolinkCache BitweenCache, SettingsService settings, BitweenDbContext dbContext) { _BitweenCache = BitweenCache; + _settings = settings; + _dbContext = dbContext; } - public Task Process(RevokeCacheMessage message) + public async Task Process(RevokeCacheMessage message) { _BitweenCache.Revoke(); - return Task.CompletedTask; + // Settings live on singletons rather than in the cache, so they need their own refresh: + // this is how an instance picks up a setting changed on a different instance. + await _settings.Reload(_dbContext); } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs b/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs index e7168dd6..61dd86e7 100644 --- a/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs +++ b/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs @@ -124,11 +124,23 @@ public async Task DocumentByNameAsync(string documentName) string.Equals(d.Name, documentName, StringComparison.CurrentCultureIgnoreCase)); } - public Task BroadcastRevoke() + public async Task BroadcastRevoke() { - using var scope = _ssf.CreateScope(); - var broadcast = scope.ServiceProvider.GetRequiredService(); - return broadcast.Broadcast(new RevokeCacheMessage()); + // This is a best-effort cache-refresh signal, not part of the write + // itself — a dozen command handlers across Subscriptions/WorkGroups/ + // BusGateways/Documents call this right after SaveChangesAsync, and an + // unhandled failure here (e.g. the RabbitMQ connection being down) + // must not turn an already-successful write into a 500 response. + try + { + using var scope = _ssf.CreateScope(); + var broadcast = scope.ServiceProvider.GetRequiredService(); + await broadcast.Broadcast(new RevokeCacheMessage()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to broadcast cache revoke; cached reads may stay stale until they expire."); + } } public async Task ListWorkGroupsAsync() diff --git a/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs b/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs index 3a070a16..4fa61860 100644 --- a/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs +++ b/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs @@ -13,7 +13,8 @@ public class NativeAdapterDiscoveryService( IEnumerable nativeMappers, IEnumerable nativeReceivers, IEnumerable nativeValidators, - IEnumerable nativeAdapters) + IEnumerable nativeAdapters, + BitweenOptions bitweenOptions) { public const string NativePrefix = "native"; public Dictionary GetStartupValues(string adapterId) @@ -148,6 +149,12 @@ public List GetNativeAdapters(string? type) return new List(); } + // Rebex-backed adapters are always registered (the license key is a setting that can + // change at runtime), so they're filtered out here while no key is set rather than + // being offered in a picker where they could only fail. + if (string.IsNullOrWhiteSpace(bitweenOptions.RebexLicenseKey)) + adapters = adapters.Where(a => a is not IRequiresRebexLicense).ToList(); + return adapters.Select(a => a.GetType().Name).ToList(); } private string? GetDefaultValue(PropertyInfo property) diff --git a/SW.Bitween.Api/Services/ReceivingJob.cs b/SW.Bitween.Api/Services/ReceivingJob.cs index 927888fc..83a10462 100644 --- a/SW.Bitween.Api/Services/ReceivingJob.cs +++ b/SW.Bitween.Api/Services/ReceivingJob.cs @@ -34,7 +34,8 @@ public async Task Execute(ReceivingJobParams jobParams) try { - var startupParameters = rec.ReceiverProperties.ToDictionary(); + var globals = await dbContext.Set().ToArrayAsync(); + var startupParameters = rec.ReceiverProperties.ToDictionary().Fill(null, globals); await RunReceiver(rec.ReceiverId, startupParameters, rec.Id); rec.SetSchedules(); rec.SetHealth(); diff --git a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs new file mode 100644 index 00000000..2a4f94f5 --- /dev/null +++ b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using SW.Scheduler; + +namespace SW.Bitween.Services; + +public enum SettingKind +{ + String, + Number, + Boolean, + Color +} + +/// What the UI is allowed to do with a setting. +public enum SettingAccess +{ + /// Stored in the Settings table and changeable from the UI. + Editable, + + /// + /// Environment-owned: shown with its current value so an administrator can see what this + /// instance is running on, but not changeable here because it's read once at startup. + /// + ReadOnly, + + /// + /// Environment-owned and private: only whether a value is set is reported, never the value — + /// for credentials and keys that would be pointless to leak into the browser. + /// + Presence +} + +/// The two option singletons a setting can read from and write to. +public sealed record SettingsTarget(BitweenOptions Bitween, ThemeOptions Theme); + +/// +/// Everything known about one setting except its value: how to show it, and how to read and +/// write it on the live options singletons. +/// +public sealed record SettingDefinition( + string Key, + string Section, + string Label, + string Description, + SettingKind Kind, + bool Secret, + Func Read, + Action Write) +{ + /// Editable unless a definition says otherwise — see . + public SettingAccess Access { get; init; } = SettingAccess.Editable; + + /// + /// Extra work needed to make a new value take effect, where assigning the property isn't + /// enough — rescheduling a job, re-declaring queues. Runs on the instance that saved it, + /// after the value has been applied. + /// + public Func OnChange { get; init; } + + /// Only editable settings get a row; the rest are read straight off the options. + public bool Stored => Access == SettingAccess.Editable; +} + +/// +/// Every setting the settings page shows, and — for the editable ones — the only keys the +/// Settings table ever holds. +/// +/// The membership rule is about , not about being listed here. A +/// setting is editable only if every consumer reads it from the options singleton per call, +/// so a change takes effect immediately. Anything captured once during +/// Startup.ConfigureServices — the bus, CORS, storage, JWT signing, the DB provider — is +/// environment-owned and appears as (its value shown) or +/// (only whether it's set), so an administrator can see what +/// the instance is running on without being offered an edit that couldn't work. +/// +/// +/// Read-only and presence settings never get a row: they're read straight off the options object, +/// which configuration bound at startup. +/// +/// +public static class SettingsCatalog +{ + public static readonly IReadOnlyList All = + [ + // ——— Documents & storage ——— + new("Bitween.AreXChangeFilesPrivate", "Documents & storage", + "Keep exchange files private", + "Turn this on if you want generated exchange files kept private and served through short-lived signed links instead of public URLs.", + SettingKind.Boolean, false, + t => Str(t.Bitween.AreXChangeFilesPrivate), + (t, v) => t.Bitween.AreXChangeFilesPrivate = Bool(v)), + + View("Bitween.DocumentPrefix", "Documents & storage", "Document prefix", + "The cloud-storage key prefix every exchange document is written under. Fixed per environment — changing it would leave everything already stored unreachable.", + SettingKind.String, t => t.Bitween.DocumentPrefix), + + // ——— API behavior ——— + new("Bitween.ApiCallSubscriptionResponseAcceptedStatusCode", "API behavior", + "Accepted response status code", + "Change this if a partner's API expects a different HTTP status code (instead of 202 Accepted) when their request has been queued for async processing.", + SettingKind.Number, false, + t => t.Bitween.ApiCallSubscriptionResponseAcceptedStatusCode?.ToString(CultureInfo.InvariantCulture), + (t, v) => t.Bitween.ApiCallSubscriptionResponseAcceptedStatusCode = NullableInt(v)), + + new("Bitween.JwtExpiryMinutes", "API behavior", + "Sign-in session length (minutes)", + "Shorten this for tighter session security, or lengthen it if teammates are being signed out more often than you'd like. Applies to sessions started after the change.", + SettingKind.Number, false, + t => t.Bitween.JwtExpiryMinutes.ToString(CultureInfo.InvariantCulture), + (t, v) => t.Bitween.JwtExpiryMinutes = Int(v)), + + View("Bitween.CorsOrigins", "API behavior", "Allowed browser origins", + "The origins allowed to call this API with cookies attached. Read once when the CORS policy is built at startup.", + SettingKind.String, t => Join(t.Bitween.CorsOrigins)), + + // ——— Single sign-on (Microsoft) ——— + // Not secrets: these are public client identifiers, and the [Unprotect] Config endpoint + // already serves them to anonymous visitors so the login page can offer the MS button. + new("Bitween.MsalClientId", "Single sign-on (Microsoft)", + "Azure AD client ID", + "Add this — together with the tenant ID and redirect URI below — if you want to let teammates sign in with a Microsoft account. All three are required for Microsoft sign-in to turn on.", + SettingKind.String, false, + t => t.Bitween.MsalClientId, + (t, v) => t.Bitween.MsalClientId = v), + + new("Bitween.MsalTenantId", "Single sign-on (Microsoft)", + "Azure AD tenant ID", + "The Azure AD tenant Microsoft sign-in is restricted to. Required alongside the client ID and redirect URI.", + SettingKind.String, false, + t => t.Bitween.MsalTenantId, + (t, v) => t.Bitween.MsalTenantId = v), + + new("Bitween.MsalRedirectUri", "Single sign-on (Microsoft)", + "Azure AD redirect URI", + "The URL Azure AD sends users back to after signing in — must match the redirect URI registered on the Azure AD app. Required alongside the client ID and tenant ID.", + SettingKind.String, false, + t => t.Bitween.MsalRedirectUri, + (t, v) => t.Bitween.MsalRedirectUri = v), + + new("Bitween.DisableEmailPasswordLogin", "Single sign-on (Microsoft)", + "Microsoft sign-in only", + "Turn this on to stop anyone signing in with an email and password — the sign-in page then offers Microsoft alone, and new teammates are added without a password. Only do this once Microsoft sign-in above is working, or nobody will be able to get in.", + SettingKind.Boolean, false, + t => Str(t.Bitween.DisableEmailPasswordLogin), + (t, v) => t.Bitween.DisableEmailPasswordLogin = Bool(v)), + + // ——— Adapters ——— + new("Bitween.RebexLicenseKey", "Adapters", + "Rebex license key", + "Add this if you want the native POP3 and FTP adapters, which are built on the Rebex library — without a key they aren't offered when picking a receiver or handler.", + SettingKind.String, true, + t => t.Bitween.RebexLicenseKey, + (t, v) => t.Bitween.RebexLicenseKey = v), + + View("Bitween.AdapterPath", "Adapters", "Custom adapter path", + "The cloud-storage key prefix custom adapter packages are downloaded from. Handed to the serverless runner when it's configured at startup.", + SettingKind.String, t => t.Bitween.AdapterPath), + + View("Bitween.ServerlessCommandTimeout", "Adapters", "Custom adapter timeout (seconds)", + "How long a custom adapter may run before the serverless runner gives up on it.", + SettingKind.Number, t => t.Bitween.ServerlessCommandTimeout.ToString(CultureInfo.InvariantCulture)), + + // ——— Reliability & jobs ——— + new("Bitween.RetryJobCron", "Reliability & jobs", + "Retry poll schedule", + "How often Bitween looks for exchanges whose scheduled retry has come due, as a cron expression: second minute hour day-of-month month day-of-week. Saving re-schedules the job straight away.", + SettingKind.String, false, + t => t.Bitween.RetryJobCron, + (t, v) => t.Bitween.RetryJobCron = Cron(v)) + { + // Assigning the property isn't enough here: the trigger already lives in the Quartz + // store, so it has to be replaced. Schedule() reads the value we just applied. + OnChange = sp => sp.GetRequiredService() + .Schedule(sp.GetRequiredService().RetryJobCron) + }, + + // ——— Messaging ——— + // All environment-owned: the bus connection and its queue topology are built once, during + // startup, so nothing here could take effect on a running instance. + View("Bitween.QueuePrefix", "Messaging", "Queue name prefix", + "Prefixed to every queue this instance declares, which is what keeps two Bitween deployments on one RabbitMQ from consuming each other's messages.", + SettingKind.String, t => t.Bitween.QueuePrefix), + + View("Bitween.BusDefaultQueuePrefetch", "Messaging", "Default queue prefetch", + "How many messages a consumer may hold unacknowledged by default. A work group can override it for its own queue.", + SettingKind.Number, t => t.Bitween.BusDefaultQueuePrefetch?.ToString(CultureInfo.InvariantCulture)), + + View("Bitween.ConsumeLegacyEventMessages", "Messaging", "Consume legacy event messages", + "Whether this instance also drains the five queues named after exchange events, which an older Bitween published to before work groups existed. Nothing publishes to them today, so this is only for finishing off messages left behind by an upgrade.", + SettingKind.Boolean, t => Str(t.Bitween.ConsumeLegacyEventMessages)), + + Presence("Bitween.RabbitMqManagementUrl", "Messaging", "RabbitMQ management URL", + "The management API queue health is read from. All three management values are needed before queue depths can be shown.", + t => t.Bitween.RabbitMqManagementUrl), + + Presence("Bitween.RabbitMqManagementUsername", "Messaging", "RabbitMQ management username", + "The account queue health reads with. Required alongside the URL and password.", + t => t.Bitween.RabbitMqManagementUsername), + + Presence("Bitween.RabbitMqManagementPassword", "Messaging", "RabbitMQ management password", + "The password for the management account. Required alongside the URL and username.", + t => t.Bitween.RabbitMqManagementPassword), + + // ——— Database ——— + View("Bitween.UseAzureManagedIdentity", "Database", "Use Azure managed identity", + "Whether database connections authenticate with an Azure managed identity instead of a password in the connection string.", + SettingKind.Boolean, t => Str(t.Bitween.UseAzureManagedIdentity)), + + Presence("Bitween.AzureManagedIdentityClientId", "Database", "Managed identity client ID", + "Set only when a user-assigned identity is used; left unset, the system-assigned identity is.", + t => t.Bitween.AzureManagedIdentityClientId), + + // ——— Security ——— + Presence("Bitween.SettingsEncryptionKey", "Security", "Settings encryption key", + "Encrypts secret settings before they're stored. Without it, a secret can't be saved here at all and stays environment-only.", + t => t.Bitween.SettingsEncryptionKey), + + // ——— Brand & theme ——— + new("Theme.PrimaryColor", "Brand & theme", + "Primary color", + "Re-brands the whole app's accent color — buttons, links, active nav, focus rings — instantly, without waiting on a deploy.", + SettingKind.Color, false, + t => t.Theme.PrimaryColor, + (t, v) => t.Theme.PrimaryColor = v), + + new("Theme.CompanyName", "Brand & theme", + "Company name", + "Shown in the footer and used in a few page titles.", + SettingKind.String, false, + t => t.Theme.CompanyName, + (t, v) => t.Theme.CompanyName = v), + + new("Theme.TabTitle", "Brand & theme", + "Browser tab title", + "What shows in the browser tab.", + SettingKind.String, false, + t => t.Theme.TabTitle, + (t, v) => t.Theme.TabTitle = v), + + new("Theme.TabIcon", "Brand & theme", + "Favicon URL", + "The icon shown in the browser tab. Paste a URL to an .ico, .svg or .png.", + SettingKind.String, false, + t => t.Theme.TabIcon, + (t, v) => t.Theme.TabIcon = v), + + new("Theme.LoginLogo", "Brand & theme", + "Sign-in page logo", + "The logo shown above the sign-in form.", + SettingKind.String, false, + t => t.Theme.LoginLogo, + (t, v) => t.Theme.LoginLogo = v), + + new("Theme.BitweenLogo", "Brand & theme", + "Sidebar logo", + "The full logo shown at the top of the sidebar.", + SettingKind.String, false, + t => t.Theme.BitweenLogo, + (t, v) => t.Theme.BitweenLogo = v), + + // Theme.BitweenIcon is deliberately absent: it configured the icon for a + // collapsed, icon-only sidebar, and this UI has no such mode. Leaving it + // editable meant a setting that silently did nothing. The ThemeOptions + // property stays so existing appsettings keep binding. + + new("Theme.BitweenHeaderIcon", "Brand & theme", + "Mobile header icon", + "Shown instead of the sidebar logo in the narrow top bar on phones. Leave it as-is to reuse the sidebar logo.", + SettingKind.String, false, + t => t.Theme.BitweenHeaderIcon, + (t, v) => t.Theme.BitweenHeaderIcon = v), + + new("Theme.BitweenText", "Brand & theme", + "Sign-in page blurb", + "The marketing description shown beside the sign-in form.", + SettingKind.String, false, + t => t.Theme.BitweenText, + (t, v) => t.Theme.BitweenText = v), + + new("Theme.ShowFooter", "Brand & theme", + "Show the footer", + "Turn this off to hide the footer everywhere — the copyright line and the website / LinkedIn / GitHub links below every page.", + SettingKind.Boolean, false, + t => Str(t.Theme.ShowFooter), + (t, v) => t.Theme.ShowFooter = Bool(v)), + + new("Theme.LinkedinLink", "Brand & theme", + "LinkedIn link", + "Add this if you want a LinkedIn link in the footer — leave blank to hide it.", + SettingKind.String, false, + t => t.Theme.LinkedinLink, + (t, v) => t.Theme.LinkedinLink = v), + + new("Theme.GithubLink", "Brand & theme", + "GitHub link", + "Add this if you want a GitHub link in the footer — leave blank to hide it.", + SettingKind.String, false, + t => t.Theme.GithubLink, + (t, v) => t.Theme.GithubLink = v), + + new("Theme.WebsiteLink", "Brand & theme", + "Website link", + "Add this if you want a company website link in the footer — leave blank to hide it.", + SettingKind.String, false, + t => t.Theme.WebsiteLink, + (t, v) => t.Theme.WebsiteLink = v), + + new("Theme.AllRightsReserved", "Brand & theme", + "Copyright notice", + "The copyright notice text shown in the footer.", + SettingKind.String, false, + t => t.Theme.AllRightsReserved, + (t, v) => t.Theme.AllRightsReserved = v), + + new("Theme.CopyRightsIcon", "Brand & theme", + "Copyright symbol", + "The symbol shown before the copyright notice.", + SettingKind.String, false, + t => t.Theme.CopyRightsIcon, + (t, v) => t.Theme.CopyRightsIcon = v) + ]; + + private static readonly Dictionary ByKey = + All.ToDictionary(d => d.Key, StringComparer.OrdinalIgnoreCase); + + public static SettingDefinition Find(string key) => + key is not null && ByKey.TryGetValue(key, out var definition) ? definition : null; + + /// + /// An environment value the UI shows but can't change. No Write: there's nowhere to + /// write it to that would matter, since whoever reads it did so at startup. + /// + private static SettingDefinition View(string key, string section, string label, string description, + SettingKind kind, Func read) => + new(key, section, label, description, kind, false, read, null) { Access = SettingAccess.ReadOnly }; + + /// An environment value reported only as set or not set, never by its content. + private static SettingDefinition Presence(string key, string section, string label, string description, + Func read) => + new(key, section, label, description, SettingKind.String, false, read, null) + { Access = SettingAccess.Presence }; + + private static string Str(bool value) => value ? "true" : "false"; + + private static string Join(string[] values) => + values is null ? string.Empty : string.Join(", ", values); + + /// + /// Rejected here rather than by the scheduler, because a bad expression that reached the table + /// would throw on the next boot — inside the background service that seeds every subscription's + /// schedule, taking all of them down with it. + /// + private static string Cron(string value) => + CronExpression.IsValidExpression(value) + ? value + : throw new FormatException($"'{value}' is not a valid cron expression."); + + /// Anything but an explicit "true" is off — matches how the UI posts checkboxes. + private static bool Bool(string value) => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + + private static int Int(string value) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : throw new FormatException($"'{value}' is not a whole number."); + + private static int? NullableInt(string value) => + string.IsNullOrWhiteSpace(value) ? null : Int(value); +} diff --git a/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs b/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs new file mode 100644 index 00000000..f486b340 --- /dev/null +++ b/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SW.Bitween.Services; + +public static class SettingsHostExtensions +{ + /// + /// Hands configuration over to the Settings table and applies what's stored, before the app + /// starts serving. Any key without a row yet is imported from configuration once; after that + /// the table is the only source. Runs after MigrateDatabase so the table is guaranteed + /// to exist; an unreachable database is logged and the app boots on its configured values + /// rather than failing to start. + /// + public static IHost ApplyStoredSettings(this IHost host) + { + using var scope = host.Services.CreateScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + + try + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + settings.ImportMissing(dbContext).GetAwaiter().GetResult(); + settings.Reload(dbContext).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + scope.ServiceProvider.GetRequiredService>() + .LogError(ex, "Could not load stored settings; starting on configured values only."); + } + + return host; + } +} diff --git a/SW.Bitween.Api/Services/Settings/SettingsProtector.cs b/SW.Bitween.Api/Services/Settings/SettingsProtector.cs new file mode 100644 index 00000000..0e5994fb --- /dev/null +++ b/SW.Bitween.Api/Services/Settings/SettingsProtector.cs @@ -0,0 +1,87 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace SW.Bitween.Services; + +/// +/// Encrypts secret settings before they're stored, so a database dump or backup never carries a +/// license key in the clear. AES-GCM with a fresh salt and nonce per value: encrypting the same +/// key twice produces different ciphertext, and tampering fails the authentication tag rather +/// than decrypting to garbage. +/// +/// The passphrase comes from — configuration +/// only, never the Settings table. When it isn't set, is false and +/// secret settings stay out of the table entirely. +/// +/// +public class SettingsProtector +{ + private const string Prefix = "enc.v1:"; + private const int SaltBytes = 16; + private const int NonceBytes = 12; + private const int TagBytes = 16; + private const int KeyBytes = 32; + private const int Iterations = 100_000; + + private readonly string _passphrase; + + public SettingsProtector(BitweenOptions options) => _passphrase = options.SettingsEncryptionKey; + + /// Whether secrets can be stored at all. False = no passphrase configured. + public bool IsConfigured => !string.IsNullOrWhiteSpace(_passphrase); + + public string Protect(string plaintext) + { + if (!IsConfigured) + throw new InvalidOperationException( + $"{BitweenOptions.ConfigurationSection}:{nameof(BitweenOptions.SettingsEncryptionKey)} is not configured."); + + if (string.IsNullOrEmpty(plaintext)) return string.Empty; + + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var nonce = RandomNumberGenerator.GetBytes(NonceBytes); + var cipher = new byte[Encoding.UTF8.GetByteCount(plaintext)]; + var tag = new byte[TagBytes]; + + using var aes = new AesGcm(DeriveKey(salt), TagBytes); + aes.Encrypt(nonce, Encoding.UTF8.GetBytes(plaintext), cipher, tag); + + var payload = new byte[SaltBytes + NonceBytes + TagBytes + cipher.Length]; + salt.CopyTo(payload, 0); + nonce.CopyTo(payload, SaltBytes); + tag.CopyTo(payload, SaltBytes + NonceBytes); + cipher.CopyTo(payload, SaltBytes + NonceBytes + TagBytes); + + return Prefix + Convert.ToBase64String(payload); + } + + /// + /// Reverses . A value without the marker is returned untouched — that's + /// how a hand-written row, or one stored before a passphrase existed, still reads back. + /// Throws when the value was written under a different passphrase or has been altered. + /// + public string Unprotect(string stored) + { + if (string.IsNullOrEmpty(stored) || !stored.StartsWith(Prefix, StringComparison.Ordinal)) + return stored; + + var payload = Convert.FromBase64String(stored[Prefix.Length..]); + if (payload.Length < SaltBytes + NonceBytes + TagBytes) + throw new CryptographicException("Encrypted setting value is truncated."); + + var salt = payload.AsSpan(0, SaltBytes); + var nonce = payload.AsSpan(SaltBytes, NonceBytes); + var tag = payload.AsSpan(SaltBytes + NonceBytes, TagBytes); + var cipher = payload.AsSpan(SaltBytes + NonceBytes + TagBytes); + var plain = new byte[cipher.Length]; + + using var aes = new AesGcm(DeriveKey(salt.ToArray()), TagBytes); + aes.Decrypt(nonce, cipher, tag, plain); + + return Encoding.UTF8.GetString(plain); + } + + private byte[] DeriveKey(byte[] salt) => + Rfc2898DeriveBytes.Pbkdf2(_passphrase, salt, Iterations, HashAlgorithmName.SHA256, KeyBytes); +} diff --git a/SW.Bitween.Api/Services/Settings/SettingsService.cs b/SW.Bitween.Api/Services/Settings/SettingsService.cs new file mode 100644 index 00000000..d6b984be --- /dev/null +++ b/SW.Bitween.Api/Services/Settings/SettingsService.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SW.Bitween.Domain; + +namespace SW.Bitween.Services; + +/// +/// Keeps the and singletons in sync with +/// the Settings table, which is the single source of truth for every editable setting. +/// +/// Configuration (env / appsettings) is read once per setting: the first boot after a key +/// exists copies what configuration says into a row (), and from then +/// on configuration is ignored for that key. "Default" therefore means the product default — +/// the initializer on the options class — which is what a reset returns a setting to. +/// +/// +/// Applying a value is just assigning the property: both option objects are singletons that every +/// consumer reads per call, so no consumer needs to know settings can change. +/// +/// +public class SettingsService +{ + /// Pristine options — never mutated, only read, to answer "what ships in the box?". + private static readonly SettingsTarget CodeDefaults = new(new BitweenOptions(), new ThemeOptions()); + + private readonly SettingsTarget _target; + private readonly SettingsProtector _protector; + private readonly ILogger _logger; + + /// + /// What configuration bound at startup. This is the import source, not the default — + /// captured before any stored value is applied, and used only for keys with no row yet. + /// + private readonly Dictionary _configured; + + public SettingsService(BitweenOptions bitweenOptions, ThemeOptions themeOptions, + SettingsProtector protector, ILogger logger) + { + _target = new SettingsTarget(bitweenOptions, themeOptions); + _protector = protector; + _logger = logger; + _configured = SettingsCatalog.All.ToDictionary(d => d.Key, d => d.Read(_target) ?? string.Empty); + } + + /// The product default: what this setting is without anyone having chosen anything. + public static string DefaultOf(SettingDefinition definition) => definition.Read(CodeDefaults) ?? string.Empty; + + /// + /// Product defaults for one key prefix, keyed the way the options object serializes + /// (Theme.LoginLogologinLogo). Lets an unauthenticated client tell a brand + /// value someone chose from one nobody has touched. + /// + public static Dictionary DefaultsUnder(string prefix) => SettingsCatalog.All + .Where(d => d.Key.StartsWith(prefix, StringComparison.Ordinal)) + .ToDictionary(d => Camelize(d.Key[prefix.Length..]), DefaultOf); + + /// + /// What this setting is right now on the live options singletons. For an environment-owned + /// setting that's the whole story — it's what configuration bound at startup. + /// + public string LiveValue(SettingDefinition definition) => definition.Read(_target) ?? string.Empty; + + /// A secret can only be stored if there's a passphrase to protect it with. + public bool CanStore(SettingDefinition definition) => + definition.Stored && (!definition.Secret || _protector.IsConfigured); + + /// Ciphertext for a secret, the value itself for everything else. + public string ToStored(SettingDefinition definition, string value) => + definition.Secret ? _protector.Protect(value) : value; + + /// + /// Copies configuration into a row for any key that doesn't have one yet — the one-time + /// hand-off that lets an existing deployment keep the values it was configured with. Runs at + /// startup only. + /// + public async Task ImportMissing(BitweenDbContext dbContext) + { + var stored = await dbContext.Set().AsNoTracking().Select(s => s.Id).ToListAsync(); + var have = new HashSet(stored, StringComparer.OrdinalIgnoreCase); + + var imported = new List(); + foreach (var definition in SettingsCatalog.All) + { + // Read-only and presence settings are environment-owned; they never get a row. + if (!definition.Stored) continue; + if (have.Contains(definition.Key)) continue; + // A secret with nowhere safe to go stays in configuration until a passphrase exists. + if (!CanStore(definition)) continue; + + var configured = _configured.GetValueOrDefault(definition.Key) ?? string.Empty; + dbContext.Add(new Setting { Id = definition.Key, Value = ToStored(definition, configured) }); + imported.Add(definition.Key); + } + + if (imported.Count == 0) return; + + try + { + await dbContext.SaveChangesAsync(); + _logger.LogInformation("Imported {Count} setting(s) from configuration: {Keys}", + imported.Count, string.Join(", ", imported)); + } + catch (DbUpdateException ex) + { + // Two instances booting together both try to import; whoever loses just reads the + // other's rows in Reload. Nothing to repair. + _logger.LogWarning(ex, "Setting import collided with another instance; using the stored rows."); + dbContext.ChangeTracker.Clear(); + } + } + + /// + /// Re-reads every stored setting and applies it. Called once before the app starts serving, + /// and again on each cache-revoke broadcast so an instance picks up a change made on another. + /// + public async Task Reload(BitweenDbContext dbContext) + { + var rows = await dbContext.Set().AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Value, StringComparer.OrdinalIgnoreCase); + + foreach (var definition in SettingsCatalog.All) + { + // Environment-owned settings stay that way even if a row is hand-inserted for one. + if (!definition.Stored) continue; + // No row means nobody could store this key yet (a secret with no passphrase), so + // whatever configuration bound at startup stands. + if (!rows.TryGetValue(definition.Key, out var stored)) continue; + + try + { + Assign(definition, definition.Secret ? _protector.Unprotect(stored) : stored); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Stored value for setting {Key} could not be read — it was likely written under a " + + "different {Option}. Leaving the running value untouched.", + definition.Key, nameof(BitweenOptions.SettingsEncryptionKey)); + } + } + } + + /// Applies one value immediately, so the request that saved it sees the effect. + public void Apply(SettingDefinition definition, string value) => Assign(definition, value); + + /// + /// Runs the real write against throwaway option objects, so the endpoint can reject "abc" + /// for a number before it reaches the database — without the live values ever seeing it. + /// + public static void Validate(SettingDefinition definition, string value) => + definition.Write(new SettingsTarget(new BitweenOptions(), new ThemeOptions()), value); + + /// + /// A bad stored value must never stop the app from booting: log it and leave the key at its + /// product default. + /// + private void Assign(SettingDefinition definition, string value) + { + try + { + definition.Write(_target, value); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Stored value for setting {Key} could not be applied; falling back to the product default.", + definition.Key); + try + { + definition.Write(_target, DefaultOf(definition)); + } + catch (Exception fallbackEx) + { + _logger.LogError(fallbackEx, "Product default for setting {Key} is itself invalid.", definition.Key); + } + } + } + + private static string Camelize(string name) => + name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..]; +} diff --git a/SW.Bitween.Api/Services/ThemeOptions.cs b/SW.Bitween.Api/Services/ThemeOptions.cs index 84f3699d..ea2300f7 100644 --- a/SW.Bitween.Api/Services/ThemeOptions.cs +++ b/SW.Bitween.Api/Services/ThemeOptions.cs @@ -1,21 +1,37 @@ namespace SW.Bitween.Services { + /// + /// Branding. Every value here is an editable setting, so these initializers are the product + /// defaults — what "reset to default" returns a setting to. A deployment can still seed + /// different values through configuration, but only once: the first boot imports them into the + /// Settings table, and from then on the table is the only source (see SettingsService). + /// public class ThemeOptions { public const string ConfigurationSection = "Theme"; - public string LoginLogo { get; set; } - public string BitweenLogo { get; set; } - public string BitweenText { get; set; } - public string LinkedinLink { get; set; } - public string GithubLink { get; set; } - public string BitweenIcon { get; set; } - public string BitweenHeaderIcon { get; set; } - public string WebsiteLink { get; set; } - public string CompanyName { get; set; } - public string AllRightsReserved { get; set; } - public string CopyRightsIcon { get; set; } - public string TabTitle { get; set; } - public string TabIcon { get; set; } + public string LoginLogo { get; set; } = "/brand/BitweenFull.svg"; + public string BitweenLogo { get; set; } = "/brand/BitweenFull.svg"; + + public string BitweenText { get; set; } = + "is all-in-one solution to solving integration with third parties, automating workflows " + + "with exchanges coming from all forms of requests, ranging from internal messages to " + + "files dumped on a server."; + + public string LinkedinLink { get; set; } = "https://www.linkedin.com/company/simplify9"; + public string GithubLink { get; set; } = "https://github.com/simplify9"; + public string BitweenIcon { get; set; } = "/brand/BitweenIcon.png"; + public string BitweenHeaderIcon { get; set; } = "/brand/BitweenIcon.svg"; + public string WebsiteLink { get; set; } = "https://www.simplify9.com/"; + public string CompanyName { get; set; } = "Simplify9"; + public string AllRightsReserved { get; set; } = "All Rights Reserved."; + public string CopyRightsIcon { get; set; } = "©"; + public string TabTitle { get; set; } = "Bitween"; + public string TabIcon { get; set; } = "/favicon.svg"; + + /// Accent color the UI derives its whole brand ramp from. Hex, e.g. #e3311d. + public string PrimaryColor { get; set; } = "#e3311d"; + + public bool ShowFooter { get; set; } = true; } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index 31f6bb7f..78c3f2eb 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -95,7 +95,10 @@ public async Task CreateXchange(Xchange xchange, XchangeFile file, WorkGroup wor public async Task CreateXchange(Subscription subscription, Xchange xchange, XchangeFile file, string[] references = null, Dictionary groupAttemptCounts = null) { - var newXchange = new Xchange(subscription, xchange, file, groupAttemptCounts); + var partnerId = xchange.PartnerId ?? subscription.PartnerId; + var partner = partnerId.HasValue ? await _dbContext.FindAsync(partnerId.Value) : null; + var globalAdapterValuesSets = await _BitweenCache.ListGlobalAdapterValuesSetsAsync(); + var newXchange = new Xchange(subscription, xchange, file, partner, globalAdapterValuesSets, groupAttemptCounts); await AddFile(newXchange.Id, XchangeFileType.Input, file); _dbContext.Add(newXchange); } @@ -114,6 +117,11 @@ public async Task CreateXchange(Subscription subscription, XchangeFile string[] references = null, string correlationId = null, Partner gatewayPartner = null, GlobalAdapterValuesSet[] globalAdapterValuesSets = null) { + // Callers that have no partner/globals context of their own (scheduled receivers, + // aggregation, manual "create exchange", plain internal subscription fan-out) leave + // this null — resolve it here so {{globals.…}} always gets a chance to translate, + // instead of silently no-op'ing for whichever caller forgot to load it. + globalAdapterValuesSets ??= await _BitweenCache.ListGlobalAdapterValuesSetsAsync(); var xchange = new Xchange(subscription, file, references, correlationId, gatewayPartner, globalAdapterValuesSets); await AddFile(xchange.Id, XchangeFileType.Input, file); diff --git a/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs b/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs new file mode 100644 index 00000000..a788ed4f --- /dev/null +++ b/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; +using SW.PrimitiveTypes; + +namespace SW.Bitween.IntegrationTests.Fixtures; + +internal static class TestRequestContext +{ + /// + /// Handlers resolve their permissions from the database, and no account is signed in during + /// these tests. The break-glass superuser claim grants the whole catalog, so a test exercises + /// the handler rather than the guard in front of it. + /// + public static RequestContext Superuser(this AsyncServiceScope scope) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Set(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(Bitween.RequestContextExtensions.SuperuserClaim, "true")], "integration-test"))); + return ctx; + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs b/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs index a410e084..1c3657b2 100644 --- a/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs @@ -121,7 +121,7 @@ public async Task DelayedRetries_Search_returns_expected_row() db.Set().Add(new DelayedRetry { Id = xchange.Id, On = scheduledOn }); await db.SaveChangesAsync(); - var search = new SW.Bitween.Resources.DelayedRetries.Search(db); + var search = new SW.Bitween.Resources.DelayedRetries.Search(db, scope.Superuser()); var response = (SearchyResponse)await search.Handle(EmptySearch()); var row = response.Result.FirstOrDefault(r => r.Id == xchange.Id); @@ -141,7 +141,7 @@ public async Task RunNow_executes_immediately_even_when_not_yet_due_and_removes_ await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9006, "Run Now Doc"); // Scheduled an hour from now — RunNow must still execute it immediately. @@ -164,7 +164,7 @@ public async Task RunNow_throws_when_nothing_is_scheduled() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var runNow = new SW.Bitween.Resources.DelayedRetries.RunNow(db, ctx, xs); @@ -186,7 +186,7 @@ public async Task Xchanges_Search_includes_ScheduledRetryOn_when_delayed_retry_e db.Set().Add(new DelayedRetry { Id = xchange.Id, On = scheduledOn }); await db.SaveChangesAsync(); - var search = new SW.Bitween.Resources.Xchanges.Search(db, xs); + var search = new SW.Bitween.Resources.Xchanges.Search(db, xs, scope.Superuser()); var response = (SearchyResponse)await search.Handle(EmptySearch()); var row = response.Result.FirstOrDefault(r => r.Id == xchange.Id); @@ -203,7 +203,7 @@ public async Task Xchanges_Search_has_null_ScheduledRetryOn_when_no_delayed_retr var xs = scope.ServiceProvider.GetRequiredService(); var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9008, "Xchange Search Unscheduled Doc"); - var search = new SW.Bitween.Resources.Xchanges.Search(db, xs); + var search = new SW.Bitween.Resources.Xchanges.Search(db, xs, scope.Superuser()); var response = (SearchyResponse)await search.Handle(EmptySearch()); var row = response.Result.FirstOrDefault(r => r.Id == xchange.Id); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 45f975f2..c144950c 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -27,7 +27,7 @@ public RetryPolicyTests(BitweenFixture fixture) private static (Create create, Get get, Update update, Delete delete) Handlers(BitweenDbContext db, RequestContext ctx) => ( new Create(db, ctx), - new Get(db), + new Get(db, ctx), new Update(db, ctx), new Delete(db, ctx)); @@ -59,7 +59,7 @@ public async Task Can_create_and_get_retry_policy() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (create, get, _, _) = Handlers(db, ctx); var id = (int)await create.Handle(SimplePolicy("Round-trip Policy")); @@ -77,7 +77,7 @@ public async Task Create_policy_with_complex_groups_round_trips_json_correctly() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (create, _, _, _) = Handlers(db, ctx); var policy = new RetryPolicyCreate @@ -129,7 +129,7 @@ public async Task Can_update_retry_policy_name_and_groups() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (create, _, update, _) = Handlers(db, ctx); var id = (int)await create.Handle(SimplePolicy("Before Update")); @@ -168,7 +168,7 @@ public async Task Can_delete_retry_policy_not_assigned_to_any_subscription() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (create, _, _, delete) = Handlers(db, ctx); var id = (int)await create.Handle(SimplePolicy("Deletable Policy")); @@ -186,7 +186,7 @@ public async Task Cannot_delete_retry_policy_that_is_assigned_to_a_subscription( { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (create, _, _, delete) = Handlers(db, ctx); var doc = new Document(7001, "Delete Guard Doc"); @@ -238,7 +238,7 @@ public async Task Subscription_retry_policy_id_is_persisted_and_fk_resolves() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var (create, _, _, _) = Handlers(db, ctx); var doc = new Document(7002, "Sub FK Doc"); @@ -339,8 +339,9 @@ public async Task Removing_retry_policy_nullifies_subscription_fk_via_set_null_c public async Task Test_simulates_consecutive_attempts_and_stops_once_blocked() { await using var scope = _fixture.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var handler = new Resources.RetryPolicies.Test(ctx); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + var handler = new Resources.RetryPolicies.Test(db, ctx); var request = new TestRetryPolicyRequest { @@ -369,8 +370,9 @@ public async Task Test_simulates_consecutive_attempts_and_stops_once_blocked() public async Task Test_rejects_success_result_type() { await using var scope = _fixture.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var handler = new Resources.RetryPolicies.Test(ctx); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + var handler = new Resources.RetryPolicies.Test(db, ctx); var request = new TestRetryPolicyRequest { @@ -386,8 +388,9 @@ public async Task Test_rejects_success_result_type() public async Task Test_reports_no_match_when_no_group_applies() { await using var scope = _fixture.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var handler = new Resources.RetryPolicies.Test(ctx); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + var handler = new Resources.RetryPolicies.Test(db, ctx); var request = new TestRetryPolicyRequest { diff --git a/SW.Bitween.MsSql/BitweenDbContext.cs b/SW.Bitween.MsSql/BitweenDbContext.cs index 6088c1ef..ddef6d38 100644 --- a/SW.Bitween.MsSql/BitweenDbContext.cs +++ b/SW.Bitween.MsSql/BitweenDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; using SW.PrimitiveTypes; using SW.Scheduler.SqlServer; @@ -6,6 +7,9 @@ namespace SW.Bitween.MsSql { public class BitweenDbContext : Bitween.BitweenDbContext { + /// Backs ids — see the note in OnModelCreating. + public const string DocumentIdSequence = "DocumentIds"; + public BitweenDbContext(DbContextOptions options, RequestContext requestContext, IPublish publish) : base(options, requestContext, publish) { } @@ -13,6 +17,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.UseSchedulerSqlServer(); + + // Documents.Id became database-generated after the table already existed. SQL Server + // cannot add IDENTITY to an existing column — EF refuses outright ("to change the + // IDENTITY property of a column, the column needs to be dropped and recreated") — and + // rebuilding Documents would mean dropping the foreign keys Subscriptions and Xchanges + // hold against it. A sequence-backed default generates ids the same way with no table + // rebuild. Postgres uses identity and MySQL AUTO_INCREMENT, which both alter in place. + modelBuilder.Entity().Property(p => p.Id).UseSequence(DocumentIdSequence); } } } diff --git a/SW.Bitween.MsSql/Migrations/20260726123726_AddDocumentCodeAndAutoId.Designer.cs b/SW.Bitween.MsSql/Migrations/20260726123726_AddDocumentCodeAndAutoId.Designer.cs new file mode 100644 index 00000000..2d40e09e --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260726123726_AddDocumentCodeAndAutoId.Designer.cs @@ -0,0 +1,1904 @@ +// +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("20260726123726_AddDocumentCodeAndAutoId")] + partial class AddDocumentCodeAndAutoId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("DocumentIds"); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260726123726_AddDocumentCodeAndAutoId.cs b/SW.Bitween.MsSql/Migrations/20260726123726_AddDocumentCodeAndAutoId.cs new file mode 100644 index 00000000..1d8071d7 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260726123726_AddDocumentCodeAndAutoId.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AddDocumentCodeAndAutoId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateSequence( + name: "DocumentIds"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Documents", + type: "int", + nullable: false, + defaultValueSql: "NEXT VALUE FOR [DocumentIds]", + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AddColumn( + name: "Code", + table: "Documents", + type: "varchar(50)", + unicode: false, + maxLength: 50, + nullable: true); + + migrationBuilder.UpdateData( + table: "Documents", + keyColumn: "Id", + keyValue: 10001, + column: "Code", + value: null); + + migrationBuilder.CreateIndex( + name: "IX_Documents_Code", + table: "Documents", + column: "Code", + unique: true, + filter: "[Code] IS NOT NULL"); + + // The sequence starts at 1; realign it past the existing (user-assigned, pre-sequence) + // ids so the next INSERT can't collide with them. RESTART WITH needs a literal, hence + // the dynamic SQL. + migrationBuilder.Sql(""" + DECLARE @next bigint = (SELECT ISNULL(MAX([Id]), 0) + 1 FROM [Documents]); + DECLARE @sql nvarchar(200) = + N'ALTER SEQUENCE [DocumentIds] RESTART WITH ' + CAST(@next AS nvarchar(20)); + EXEC sp_executesql @sql; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Documents_Code", + table: "Documents"); + + migrationBuilder.DropColumn( + name: "Code", + table: "Documents"); + + migrationBuilder.DropSequence( + name: "DocumentIds"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Documents", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int", + oldDefaultValueSql: "NEXT VALUE FOR [DocumentIds]"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260726123818_AddRolesAndPermissions.Designer.cs b/SW.Bitween.MsSql/Migrations/20260726123818_AddRolesAndPermissions.Designer.cs new file mode 100644 index 00000000..0fb443f7 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260726123818_AddRolesAndPermissions.Designer.cs @@ -0,0 +1,2006 @@ +// +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("20260726123818_AddRolesAndPermissions")] + partial class AddRolesAndPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("DocumentIds"); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260726123818_AddRolesAndPermissions.cs b/SW.Bitween.MsSql/Migrations/20260726123818_AddRolesAndPermissions.cs new file mode 100644 index 00000000..33fa07c5 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260726123818_AddRolesAndPermissions.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AddRolesAndPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + Permissions = table.Column(type: "nvarchar(max)", nullable: true), + IsSystem = table.Column(type: "bit", nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AccountRoles", + columns: table => new + { + AccountId = table.Column(type: "int", nullable: false), + RoleId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountRoles", x => new { x.AccountId, x.RoleId }); + table.ForeignKey( + name: "FK_AccountRoles_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AccountRoles_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Roles", + columns: new[] { "Id", "CreatedBy", "CreatedOn", "Description", "IsSystem", "ModifiedBy", "ModifiedOn", "Name", "Permissions" }, + values: new object[,] + { + { 1, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Full access to everything, including members, roles and settings.", true, null, null, "Administrator", "[]" }, + { 2, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Runs and configures integrations. Can't manage members, roles or settings.", true, null, null, "Member", "[]" }, + { 3, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Read-only access to integrations, exchanges and configuration.", true, null, null, "Viewer", "[]" } + }); + + migrationBuilder.CreateIndex( + name: "IX_AccountRoles_RoleId", + table: "AccountRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_Roles_Name", + table: "Roles", + column: "Name", + unique: true); + + // Every existing account keeps exactly the access it had: its coarse AccountRole + // (Admin=0, Viewer=10, Member=20) becomes the matching built-in role. Anything + // unrecognised lands on Viewer — least privilege. Idempotent, so re-running or + // ordering against the seeded admin account can't produce duplicates. + migrationBuilder.Sql(""" + INSERT INTO [AccountRoles] ([AccountId], [RoleId]) + SELECT a.[Id], CASE a.[Role] WHEN 0 THEN 1 WHEN 20 THEN 2 ELSE 3 END + FROM [Accounts] a + WHERE NOT EXISTS ( + SELECT 1 FROM [AccountRoles] l WHERE l.[AccountId] = a.[Id]); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountRoles"); + + migrationBuilder.DropTable( + name: "Roles"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260726151528_DropAccountPhone.Designer.cs b/SW.Bitween.MsSql/Migrations/20260726151528_DropAccountPhone.Designer.cs new file mode 100644 index 00000000..b4c7b400 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260726151528_DropAccountPhone.Designer.cs @@ -0,0 +1,2001 @@ +// +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("20260726151528_DropAccountPhone")] + partial class DropAccountPhone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("DocumentIds"); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260726151528_DropAccountPhone.cs b/SW.Bitween.MsSql/Migrations/20260726151528_DropAccountPhone.cs new file mode 100644 index 00000000..00cb7c14 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260726151528_DropAccountPhone.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class DropAccountPhone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Phone", + table: "Accounts"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Phone", + table: "Accounts", + type: "varchar(20)", + unicode: false, + maxLength: 20, + nullable: true); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + column: "Phone", + value: null); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260727133106_AddSettings.Designer.cs b/SW.Bitween.MsSql/Migrations/20260727133106_AddSettings.Designer.cs new file mode 100644 index 00000000..690c1db1 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260727133106_AddSettings.Designer.cs @@ -0,0 +1,2028 @@ +// +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("20260727133106_AddSettings")] + partial class AddSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("DocumentIds"); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260727133106_AddSettings.cs b/SW.Bitween.MsSql/Migrations/20260727133106_AddSettings.cs new file mode 100644 index 00000000..82c5365a --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260727133106_AddSettings.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AddSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true), + CreatedOn = table.Column(type: "datetime2", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Settings"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 43e9d519..8b393eb8 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -22,6 +22,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.HasSequence("DocumentIds"); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => { b.Property("Id") @@ -69,11 +71,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(500)"); - b.Property("Phone") - .HasMaxLength(20) - .IsUnicode(false) - .HasColumnType("varchar(20)"); - b.Property("Role") .HasColumnType("int"); @@ -101,6 +98,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") @@ -124,6 +136,78 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RefreshTokens", (string)null); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => { b.Property("Id") @@ -147,7 +231,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SW.Bitween.Domain.Document", b => { b.Property("Id") - .HasColumnType("int"); + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); b.Property("BusEnabled") .HasColumnType("bit"); @@ -157,6 +245,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(500)"); + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + b.Property("DisregardsUnfilteredMessages") .HasColumnType("bit"); @@ -181,6 +274,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasFilter("[BusMessageTypeName] IS NOT NULL"); + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + b.HasIndex("Name") .IsUnique(); @@ -531,6 +628,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1535,6 +1659,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("QRTZ_triggers", "dbo"); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => { b.HasOne("SW.Bitween.Domain.Accounts.Account", null) diff --git a/SW.Bitween.MySql/Migrations/20260726123628_AddDocumentCodeAndAutoId.Designer.cs b/SW.Bitween.MySql/Migrations/20260726123628_AddDocumentCodeAndAutoId.Designer.cs new file mode 100644 index 00000000..991b2d09 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260726123628_AddDocumentCodeAndAutoId.Designer.cs @@ -0,0 +1,1897 @@ +// +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("20260726123628_AddDocumentCodeAndAutoId")] + partial class AddDocumentCodeAndAutoId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260726123628_AddDocumentCodeAndAutoId.cs b/SW.Bitween.MySql/Migrations/20260726123628_AddDocumentCodeAndAutoId.cs new file mode 100644 index 00000000..c32df956 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260726123628_AddDocumentCodeAndAutoId.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AddDocumentCodeAndAutoId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Id", + table: "Documents", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int") + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn); + + migrationBuilder.AddColumn( + name: "Code", + table: "Documents", + type: "varchar(50)", + unicode: false, + maxLength: 50, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.UpdateData( + table: "Documents", + keyColumn: "Id", + keyValue: 10001, + column: "Code", + value: null); + + migrationBuilder.CreateIndex( + name: "IX_Documents_Code", + table: "Documents", + column: "Code", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Documents_Code", + table: "Documents"); + + migrationBuilder.DropColumn( + name: "Code", + table: "Documents"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Documents", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int") + .OldAnnotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260726123824_AddRolesAndPermissions.Designer.cs b/SW.Bitween.MySql/Migrations/20260726123824_AddRolesAndPermissions.Designer.cs new file mode 100644 index 00000000..9c4272f0 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260726123824_AddRolesAndPermissions.Designer.cs @@ -0,0 +1,1999 @@ +// +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("20260726123824_AddRolesAndPermissions")] + partial class AddRolesAndPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260726123824_AddRolesAndPermissions.cs b/SW.Bitween.MySql/Migrations/20260726123824_AddRolesAndPermissions.cs new file mode 100644 index 00000000..7b5ba487 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260726123824_AddRolesAndPermissions.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AddRolesAndPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Permissions = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + IsSystem = table.Column(type: "tinyint(1)", nullable: false), + CreatedOn = table.Column(type: "datetime(6)", nullable: false), + CreatedBy = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ModifiedOn = table.Column(type: "datetime(6)", nullable: true), + ModifiedBy = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "AccountRoles", + columns: table => new + { + AccountId = table.Column(type: "int", nullable: false), + RoleId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountRoles", x => new { x.AccountId, x.RoleId }); + table.ForeignKey( + name: "FK_AccountRoles_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AccountRoles_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.InsertData( + table: "Roles", + columns: new[] { "Id", "CreatedBy", "CreatedOn", "Description", "IsSystem", "ModifiedBy", "ModifiedOn", "Name", "Permissions" }, + values: new object[,] + { + { 1, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Full access to everything, including members, roles and settings.", true, null, null, "Administrator", "[]" }, + { 2, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Runs and configures integrations. Can't manage members, roles or settings.", true, null, null, "Member", "[]" }, + { 3, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Read-only access to integrations, exchanges and configuration.", true, null, null, "Viewer", "[]" } + }); + + migrationBuilder.CreateIndex( + name: "IX_AccountRoles_RoleId", + table: "AccountRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_Roles_Name", + table: "Roles", + column: "Name", + unique: true); + + // Every existing account keeps exactly the access it had: its coarse AccountRole + // (Admin=0, Viewer=10, Member=20) becomes the matching built-in role. Anything + // unrecognised lands on Viewer — least privilege. Idempotent, so re-running or + // ordering against the seeded admin account can't produce duplicates. + migrationBuilder.Sql(""" + INSERT INTO `AccountRoles` (`AccountId`, `RoleId`) + SELECT a.`Id`, CASE a.`Role` WHEN 0 THEN 1 WHEN 20 THEN 2 ELSE 3 END + FROM `Accounts` a + WHERE NOT EXISTS ( + SELECT 1 FROM `AccountRoles` l WHERE l.`AccountId` = a.`Id`); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountRoles"); + + migrationBuilder.DropTable( + name: "Roles"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260726151533_DropAccountPhone.Designer.cs b/SW.Bitween.MySql/Migrations/20260726151533_DropAccountPhone.Designer.cs new file mode 100644 index 00000000..4102d5ff --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260726151533_DropAccountPhone.Designer.cs @@ -0,0 +1,1994 @@ +// +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("20260726151533_DropAccountPhone")] + partial class DropAccountPhone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260726151533_DropAccountPhone.cs b/SW.Bitween.MySql/Migrations/20260726151533_DropAccountPhone.cs new file mode 100644 index 00000000..e19d8877 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260726151533_DropAccountPhone.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class DropAccountPhone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Phone", + table: "Accounts"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Phone", + table: "Accounts", + type: "varchar(20)", + unicode: false, + maxLength: 20, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + column: "Phone", + value: null); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260727133053_AddSettings.Designer.cs b/SW.Bitween.MySql/Migrations/20260727133053_AddSettings.Designer.cs new file mode 100644 index 00000000..e61dd77f --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260727133053_AddSettings.Designer.cs @@ -0,0 +1,2021 @@ +// +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("20260727133053_AddSettings")] + partial class AddSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("GroupAttemptCounts") + .HasColumnType("longtext"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260727133053_AddSettings.cs b/SW.Bitween.MySql/Migrations/20260727133053_AddSettings.cs new file mode 100644 index 00000000..b93fdb8a --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260727133053_AddSettings.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AddSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Value = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedOn = table.Column(type: "datetime(6)", nullable: false), + CreatedBy = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ModifiedOn = table.Column(type: "datetime(6)", nullable: true), + ModifiedBy = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Settings"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index ee14e415..66cd9ef7 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -69,11 +69,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(500)"); - b.Property("Phone") - .HasMaxLength(20) - .IsUnicode(false) - .HasColumnType("varchar(20)"); - b.Property("Role") .HasColumnType("int"); @@ -100,6 +95,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") @@ -123,6 +133,78 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RefreshTokens", (string)null); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => { b.Property("Id") @@ -146,8 +228,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SW.Bitween.Domain.Document", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("int"); + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("BusEnabled") .HasColumnType("tinyint(1)"); @@ -156,6 +241,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(500)"); + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + b.Property("DisregardsUnfilteredMessages") .HasColumnType("tinyint(1)"); @@ -179,6 +269,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("BusMessageTypeName") .IsUnique(); + b.HasIndex("Code") + .IsUnique(); + b.HasIndex("Name") .IsUnique(); @@ -529,6 +622,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1532,6 +1652,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("QRTZ_triggers", (string)null); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => { b.HasOne("SW.Bitween.Domain.Accounts.Account", null) diff --git a/SW.Bitween.NativeAdapters/Interfaces.cs b/SW.Bitween.NativeAdapters/Interfaces.cs index b03f2083..a204ce5b 100644 --- a/SW.Bitween.NativeAdapters/Interfaces.cs +++ b/SW.Bitween.NativeAdapters/Interfaces.cs @@ -9,6 +9,13 @@ public interface INativeAdapter public Type StartupValuesType { get; } } +/// +/// Marks an adapter that only works with a Rebex license key configured. Such adapters are +/// always registered — the key is a setting that can change at runtime — but they're kept out +/// of the adapter pickers while no key is set. +/// +public interface IRequiresRebexLicense { } + public interface INativeInfolinkHandler : INativeAdapter, IInfolinkHandler { } public interface INativeInfolinkMapper : INativeAdapter, IInfolinkHandler { } public interface INativeInfolinkValidator : IInfolinkValidator, INativeAdapter { } diff --git a/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs index 18e8335b..65dadebf 100644 --- a/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs +++ b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs @@ -4,7 +4,7 @@ namespace SW.Bitween.NativeAdapters.RebexFtpReceiver; -public class NativeRebexFtpReceiver : INativeInfolinkReceiver +public class NativeRebexFtpReceiver : INativeInfolinkReceiver, IRequiresRebexLicense { private readonly string? _licenseKey; private RebexFtpReceiverInput _options = new(); diff --git a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs index bee8c876..7041bf85 100644 --- a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs +++ b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs @@ -4,7 +4,7 @@ namespace SW.Bitween.NativeAdapters.RebexFtpUploadHandler; -public class NativeRebexFtpUploadHandler : INativeInfolinkHandler +public class NativeRebexFtpUploadHandler : INativeInfolinkHandler, IRequiresRebexLicense { private readonly string? _licenseKey; private RebexFtpUploadHandlerInput _options = new(); diff --git a/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs b/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs index 7ef949f7..328de326 100644 --- a/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs +++ b/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs @@ -4,7 +4,7 @@ namespace SW.Bitween.NativeAdapters.RebexPop3Receiver; -public class NativeRebexPop3Receiver : INativeInfolinkReceiver +public class NativeRebexPop3Receiver : INativeInfolinkReceiver, IRequiresRebexLicense { private readonly string? _licenseKey; diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index 5f0c882a..fb0d0750 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -13,7 +13,13 @@ namespace SW.Bitween.NativeAdapters; public static class ServiceCollectionExtensions { - public static void AddNativeAdapters(this IServiceCollection serviceCollection, string? rebexLicenseKey = null) + /// + /// Reads the current Rebex license key. Called per adapter instance rather than once here, + /// because the key is a setting that can be changed while the app is running — pasting one in + /// makes the Rebex adapters usable without a restart. + /// + public static void AddNativeAdapters(this IServiceCollection serviceCollection, + Func? rebexLicenseKey = null) { serviceCollection.ConfigureHttpClientDefaults(builder => { @@ -54,16 +60,16 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection, serviceCollection.AddScoped(); serviceCollection.AddScoped(); - if (!string.IsNullOrEmpty(rebexLicenseKey)) + if (rebexLicenseKey is not null) { - serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey)); - serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey)); + serviceCollection.AddScoped(sp => new NativeRebexPop3Receiver(rebexLicenseKey(sp))); + serviceCollection.AddScoped(sp => new NativeRebexPop3Receiver(rebexLicenseKey(sp))); - serviceCollection.AddScoped(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey)); - serviceCollection.AddScoped(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey)); + serviceCollection.AddScoped(sp => new NativeRebexFtpUploadHandler(rebexLicenseKey(sp))); + serviceCollection.AddScoped(sp => new NativeRebexFtpUploadHandler(rebexLicenseKey(sp))); - serviceCollection.AddScoped(_ => new NativeRebexFtpReceiver(rebexLicenseKey)); - serviceCollection.AddScoped(_ => new NativeRebexFtpReceiver(rebexLicenseKey)); + serviceCollection.AddScoped(sp => new NativeRebexFtpReceiver(rebexLicenseKey(sp))); + serviceCollection.AddScoped(sp => new NativeRebexFtpReceiver(rebexLicenseKey(sp))); } } } \ No newline at end of file diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index b159f6ce..44f45cc3 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -41,12 +41,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(b => { //b.ToTable("Documents"); - b.Property(p => p.Id).ValueGeneratedNever(); b.Property(p => p.Name).HasMaxLength(100).IsRequired(); + b.Property(p => p.Code).HasMaxLength(50); b.Property(p => p.BusMessageTypeName).HasMaxLength(500); b.Property(p => p.PromotedProperties).StoreAsJson().HasColumnType("jsonb"); b.Property(p => p.DisregardsUnfilteredMessages).IsRequired(false); b.HasIndex(p => p.Name).IsUnique(); + b.HasIndex(p => p.Code).IsUnique(); b.HasIndex(p => p.BusMessageTypeName).IsUnique(); b.HasMany().WithOne().HasForeignKey(p => p.DocumentId).OnDelete(DeleteBehavior.Restrict); @@ -320,7 +321,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(p => p.Email).IsUnique(); b.Property(p => p.Email).IsUnicode(false).HasMaxLength(200); - b.Property(p => p.Phone).IsUnicode(false).HasMaxLength(20); b.Property(p => p.Password).IsUnicode(false).HasMaxLength(500); b.Property(p => p.DisplayName).IsRequired().HasMaxLength(200); @@ -354,6 +354,37 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.LoginMethod).HasConversion(); }); + modelBuilder.Entity(b => + { + b.ToTable("Roles"); + b.HasKey(p => p.Id); + b.Property(p => p.Id).ValueGeneratedOnAdd(); + b.HasIndex(p => p.Name).IsUnique(); + + b.Property(p => p.Name).IsRequired().HasMaxLength(100); + b.Property(p => p.Description).HasMaxLength(500); + b.Property(p => p.Permissions).StoreAsJson(); + + b.HasData(SystemRoleSeed()); + }); + + modelBuilder.Entity(b => + { + b.ToTable("AccountRoles"); + b.HasKey(p => new { p.AccountId, p.RoleId }); + b.HasOne().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade); + b.HasOne().WithMany().HasForeignKey(p => p.RoleId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(b => + { + b.ToTable("Settings"); + b.HasKey(p => p.Id); + // Id is the catalog key, e.g. "Theme.PrimaryColor". Value is left unbounded: + // it carries anything from a hex color to a license key or a page of blurb. + b.Property(p => p.Id).IsUnicode(false).HasMaxLength(200); + }); + modelBuilder.Entity(b => { b.HasKey(p => p.Id); diff --git a/SW.Bitween.PgSql/Migrations/20260719141125_AddDocumentCodeAndAutoId.Designer.cs b/SW.Bitween.PgSql/Migrations/20260719141125_AddDocumentCodeAndAutoId.Designer.cs new file mode 100644 index 00000000..77f9ae78 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260719141125_AddDocumentCodeAndAutoId.Designer.cs @@ -0,0 +1,2171 @@ +// +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("20260719141125_AddDocumentCodeAndAutoId")] + partial class AddDocumentCodeAndAutoId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("phone"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("jsonb") + .HasColumnName("promoted_properties"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.HasKey("Id") + .HasName("pk_document_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_document_trail_created_on"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_document_trail_document_id"); + + b.ToTable("document_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property>("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.Property("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property>("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property>("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_subscription_trail_created_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_subscription_trail_subscription_id"); + + b.ToTable("subscription_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("DeliveredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_on"); + + b.HasKey("Id") + .HasName("pk_xchange_delivery"); + + b.HasIndex("DeliveredOn") + .HasDatabaseName("ix_xchange_delivery_delivered_on"); + + b.ToTable("xchange_delivery", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.HasIndex("PropertiesRaw") + .HasDatabaseName("ix_xchange_promoted_properties_properties_raw"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_document_trail_document_document_id"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_trail_subscription_subscription_id"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_delivery_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260719141125_AddDocumentCodeAndAutoId.cs b/SW.Bitween.PgSql/Migrations/20260719141125_AddDocumentCodeAndAutoId.cs new file mode 100644 index 00000000..7c5b10da --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260719141125_AddDocumentCodeAndAutoId.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AddDocumentCodeAndAutoId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "id", + schema: "infolink", + table: "document", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AddColumn( + name: "code", + schema: "infolink", + table: "document", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.UpdateData( + schema: "infolink", + table: "document", + keyColumn: "id", + keyValue: 10001, + column: "code", + value: null); + + // Code is optional — existing rows stay code = NULL rather than being + // backfilled from name, which risked duplicate-derived codes colliding + // against the unique index on environments with less controlled data. + migrationBuilder.CreateIndex( + name: "ix_document_code", + schema: "infolink", + table: "document", + column: "code", + unique: true); + + // The identity sequence starts at 1; realign it past the existing (user-assigned, + // pre-identity) ids so the next INSERT doesn't eventually collide with them. + migrationBuilder.Sql(@" + SELECT setval( + pg_get_serial_sequence('infolink.document', 'id'), + (SELECT COALESCE(MAX(id), 0) FROM infolink.document));"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_document_code", + schema: "infolink", + table: "document"); + + migrationBuilder.DropColumn( + name: "code", + schema: "infolink", + table: "document"); + + migrationBuilder.AlterColumn( + name: "id", + schema: "infolink", + table: "document", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260726105636_AddRolesAndPermissions.Designer.cs b/SW.Bitween.PgSql/Migrations/20260726105636_AddRolesAndPermissions.Designer.cs new file mode 100644 index 00000000..8032a518 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260726105636_AddRolesAndPermissions.Designer.cs @@ -0,0 +1,2290 @@ +// +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("20260726105636_AddRolesAndPermissions")] + partial class AddRolesAndPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("phone"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("jsonb") + .HasColumnName("promoted_properties"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.HasKey("Id") + .HasName("pk_document_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_document_trail_created_on"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_document_trail_document_id"); + + b.ToTable("document_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property>("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.Property("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property>("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property>("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_subscription_trail_created_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_subscription_trail_subscription_id"); + + b.ToTable("subscription_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("DeliveredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_on"); + + b.HasKey("Id") + .HasName("pk_xchange_delivery"); + + b.HasIndex("DeliveredOn") + .HasDatabaseName("ix_xchange_delivery_delivered_on"); + + b.ToTable("xchange_delivery", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.HasIndex("PropertiesRaw") + .HasDatabaseName("ix_xchange_promoted_properties_properties_raw"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_document_trail_document_document_id"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_trail_subscription_subscription_id"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_delivery_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260726105636_AddRolesAndPermissions.cs b/SW.Bitween.PgSql/Migrations/20260726105636_AddRolesAndPermissions.cs new file mode 100644 index 00000000..dd4ea6fa --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260726105636_AddRolesAndPermissions.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AddRolesAndPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + schema: "infolink", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + permissions = table.Column(type: "text", nullable: true), + is_system = table.Column(type: "boolean", nullable: false), + created_on = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "text", nullable: true), + modified_on = table.Column(type: "timestamp with time zone", nullable: true), + modified_by = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_roles", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "AccountRoles", + schema: "infolink", + columns: table => new + { + account_id = table.Column(type: "integer", nullable: false), + role_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_account_roles", x => new { x.account_id, x.role_id }); + table.ForeignKey( + name: "fk_account_roles_accounts_account_id", + column: x => x.account_id, + principalSchema: "infolink", + principalTable: "Accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_account_roles_roles_role_id", + column: x => x.role_id, + principalSchema: "infolink", + principalTable: "Roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + schema: "infolink", + table: "Roles", + columns: new[] { "id", "created_by", "created_on", "description", "is_system", "modified_by", "modified_on", "name", "permissions" }, + values: new object[,] + { + { 1, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Full access to everything, including members, roles and settings.", true, null, null, "Administrator", "[]" }, + { 2, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Runs and configures integrations. Can't manage members, roles or settings.", true, null, null, "Member", "[]" }, + { 3, null, new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), "Read-only access to integrations, exchanges and configuration.", true, null, null, "Viewer", "[]" } + }); + + migrationBuilder.CreateIndex( + name: "ix_account_roles_role_id", + schema: "infolink", + table: "AccountRoles", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "ix_roles_name", + schema: "infolink", + table: "Roles", + column: "name", + unique: true); + + // Every existing account keeps exactly the access it had: its coarse AccountRole + // (Admin=0, Viewer=10, Member=20) becomes the matching built-in role. Anything + // unrecognised lands on Viewer — least privilege. Idempotent, so re-running or + // ordering against the seeded admin account can't produce duplicates. + migrationBuilder.Sql(""" + INSERT INTO infolink."AccountRoles" (account_id, role_id) + SELECT a.id, CASE a."role" WHEN 0 THEN 1 WHEN 20 THEN 2 ELSE 3 END + FROM infolink."Accounts" a + WHERE NOT EXISTS ( + SELECT 1 FROM infolink."AccountRoles" l WHERE l.account_id = a.id); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountRoles", + schema: "infolink"); + + migrationBuilder.DropTable( + name: "Roles", + schema: "infolink"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260726151523_DropAccountPhone.Designer.cs b/SW.Bitween.PgSql/Migrations/20260726151523_DropAccountPhone.Designer.cs new file mode 100644 index 00000000..5c169d68 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260726151523_DropAccountPhone.Designer.cs @@ -0,0 +1,2284 @@ +// +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("20260726151523_DropAccountPhone")] + partial class DropAccountPhone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("jsonb") + .HasColumnName("promoted_properties"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.HasKey("Id") + .HasName("pk_document_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_document_trail_created_on"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_document_trail_document_id"); + + b.ToTable("document_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property>("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.Property("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property>("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property>("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_subscription_trail_created_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_subscription_trail_subscription_id"); + + b.ToTable("subscription_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("DeliveredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_on"); + + b.HasKey("Id") + .HasName("pk_xchange_delivery"); + + b.HasIndex("DeliveredOn") + .HasDatabaseName("ix_xchange_delivery_delivered_on"); + + b.ToTable("xchange_delivery", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.HasIndex("PropertiesRaw") + .HasDatabaseName("ix_xchange_promoted_properties_properties_raw"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_document_trail_document_document_id"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_trail_subscription_subscription_id"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_delivery_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260726151523_DropAccountPhone.cs b/SW.Bitween.PgSql/Migrations/20260726151523_DropAccountPhone.cs new file mode 100644 index 00000000..4955d9b5 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260726151523_DropAccountPhone.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class DropAccountPhone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "phone", + schema: "infolink", + table: "Accounts"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "phone", + schema: "infolink", + table: "Accounts", + type: "character varying(20)", + unicode: false, + maxLength: 20, + nullable: true); + + migrationBuilder.UpdateData( + schema: "infolink", + table: "Accounts", + keyColumn: "id", + keyValue: 9999, + column: "phone", + value: null); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260727133034_AddSettings.Designer.cs b/SW.Bitween.PgSql/Migrations/20260727133034_AddSettings.Designer.cs new file mode 100644 index 00000000..fcc5674b --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260727133034_AddSettings.Designer.cs @@ -0,0 +1,2318 @@ +// +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("20260727133034_AddSettings")] + partial class AddSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("jsonb") + .HasColumnName("promoted_properties"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.HasKey("Id") + .HasName("pk_document_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_document_trail_created_on"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_document_trail_document_id"); + + b.ToTable("document_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property>("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.Property("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property>("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property>("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_subscription_trail_created_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_subscription_trail_subscription_id"); + + b.ToTable("subscription_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("DeliveredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_on"); + + b.HasKey("Id") + .HasName("pk_xchange_delivery"); + + b.HasIndex("DeliveredOn") + .HasDatabaseName("ix_xchange_delivery_delivered_on"); + + b.ToTable("xchange_delivery", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.HasIndex("PropertiesRaw") + .HasDatabaseName("ix_xchange_promoted_properties_properties_raw"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_document_trail_document_document_id"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_trail_subscription_subscription_id"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_delivery_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260727133034_AddSettings.cs b/SW.Bitween.PgSql/Migrations/20260727133034_AddSettings.cs new file mode 100644 index 00000000..332f5711 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260727133034_AddSettings.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AddSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Settings", + schema: "infolink", + columns: table => new + { + id = table.Column(type: "character varying(200)", unicode: false, maxLength: 200, nullable: false), + value = table.Column(type: "text", nullable: true), + created_on = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "text", nullable: true), + modified_on = table.Column(type: "timestamp with time zone", nullable: true), + modified_by = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_settings", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Settings", + schema: "infolink"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 8cea92fe..70683aaf 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -84,12 +84,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(500)") .HasColumnName("password"); - b.Property("Phone") - .HasMaxLength(20) - .IsUnicode(false) - .HasColumnType("character varying(20)") - .HasColumnName("phone"); - b.Property("Role") .HasColumnType("integer") .HasColumnName("role"); @@ -119,6 +113,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") @@ -148,6 +161,89 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RefreshTokens", "infolink"); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => { b.Property("Id") @@ -175,9 +271,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); @@ -187,6 +286,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -216,6 +320,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("ix_document_bus_message_type_name"); + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + b.HasIndex("Name") .IsUnique() .HasDatabaseName("ix_document_name"); @@ -647,6 +755,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -941,7 +1083,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("document_id"); - b.Property>("GroupAttemptCounts") + b.Property>("GroupAttemptCounts") .HasColumnType("jsonb") .HasColumnName("group_attempt_counts"); @@ -1767,6 +1909,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("qrtz_triggers", "infolink"); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => { b.HasOne("SW.Bitween.Domain.Accounts.Account", null) diff --git a/SW.Bitween.Sdk/Model/Account.cs b/SW.Bitween.Sdk/Model/Account.cs index c112c9d4..b98990bb 100644 --- a/SW.Bitween.Sdk/Model/Account.cs +++ b/SW.Bitween.Sdk/Model/Account.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace SW.Bitween.Model; @@ -7,13 +8,26 @@ public class CreateAccountModel public string Name { get; set; } public string Email { get; set; } public string Password { get; set; } - public int Role { get; set; } + + /// + /// Legacy coarse role. Nullable on purpose: when it was a plain int, a request that omitted it + /// sent 0 — which is Admin — so a member added with no roles came out an administrator. + /// + public int? Role { get; set; } + + /// Roles to grant. Preferred over , which is legacy. + public List RoleIds { get; set; } = []; } public class UpdateAccountModel { public string Name { get; set; } - public int Role { get; set; } + + /// + /// Legacy coarse role. Nullable on purpose: when it was a plain int, a request that omitted it + /// sent 0 — which is Admin. Only supplied values are applied, and only with users.edit. + /// + public int? Role { get; set; } } public class RemoveAccountModel @@ -27,14 +41,32 @@ public class SearchMembersModel public bool Lookup { get; set; } } +public class AccountRoleSummary +{ + public int Id { get; set; } + public string Name { get; set; } +} + public class AccountModel { public string Name { get; set; } public int Id { get; set; } public string Email { get; set; } + /// + /// Legacy coarse role, kept for older clients. Authorization reads . + /// public string Role { get; set; } + + public bool Disabled { get; set; } public DateTime CreatedOn { get; set; } + public List Roles { get; set; } = []; +} + +/// The signed-in account, plus everything the UI needs to decide what to show. +public class ProfileModel : AccountModel +{ + public List Permissions { get; set; } = []; } public class ChangePasswordModel @@ -42,4 +74,21 @@ public class ChangePasswordModel public string NewPassword { get; set; } public string OldPassword { get; set; } -} \ No newline at end of file +} + +/// Replaces the whole set of roles a member holds. +public class SetAccountRolesModel +{ + public List RoleIds { get; set; } = []; +} + +public class SetAccountDisabledModel +{ + public bool Disabled { get; set; } +} + +/// An administrator setting someone else's password, standing in for a reset flow. +public class SetAccountPasswordModel +{ + public string Password { get; set; } +} diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs index edc9fa86..0a62b0a4 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs @@ -108,6 +108,10 @@ public RetryDecision Evaluate( .Where(g => g.Enabled && g.AppliesTo.Contains(resultType)) .OrderBy(g => g.Priority)) { + // No conditions configured at all means "match every applicable failure". + if (group.Matchers.Count == 0) + return group; + var compatibleMatchers = group.Matchers.Where(m => m.ResultType == resultType); if (compatibleMatchers.Any(m => m.IsMatch(content))) return group; diff --git a/SW.Bitween.Sdk/Model/DelayedRetryModel.cs b/SW.Bitween.Sdk/Model/DelayedRetryModel.cs index cc712856..7bef1b0e 100644 --- a/SW.Bitween.Sdk/Model/DelayedRetryModel.cs +++ b/SW.Bitween.Sdk/Model/DelayedRetryModel.cs @@ -12,6 +12,16 @@ public class DelayedRetryRow public string DocumentName { get; set; } public string Exception { get; set; } public DateTime StartedOn { get; set; } + + /// + /// The shared retry policy the subscription currently points at. Null when the + /// subscription carries an inline CustomRetryPolicy instead — a delayed retry + /// can only be scheduled by one or the other, so null means "look on the subscription". + /// Reflects the policy as it stands now, not necessarily the one that set . + /// + public int? RetryPolicyId { get; set; } + + public string RetryPolicyName { get; set; } } public class DelayedRetryRunNow diff --git a/SW.Bitween.Sdk/Model/Document.cs b/SW.Bitween.Sdk/Model/Document.cs index ed001b91..fc54bffe 100644 --- a/SW.Bitween.Sdk/Model/Document.cs +++ b/SW.Bitween.Sdk/Model/Document.cs @@ -14,9 +14,11 @@ public enum DocumentFormat public class DocumentCreate : IName { - public int Id { get; set; } + public string Code { get; set; } public DocumentFormat DocumentFormat { get; set; } public string Name { get; set; } + public bool BusEnabled { get; set; } + public string BusMessageTypeName { get; set; } } public class SearchDocumentTrailModel @@ -33,8 +35,7 @@ public class DocumentTrailModel : TrailBaseModel public class DocumentUpdate : DocumentCreate { - public bool BusEnabled { get; set; } - public string BusMessageTypeName { get; set; } + public int Id { get; set; } public int DuplicateInterval { get; set; } public bool DisregardsUnfilteredMessages { get; set; } diff --git a/SW.Bitween.Sdk/Model/Partner.cs b/SW.Bitween.Sdk/Model/Partner.cs index fa977724..22bcb4c9 100644 --- a/SW.Bitween.Sdk/Model/Partner.cs +++ b/SW.Bitween.Sdk/Model/Partner.cs @@ -13,7 +13,12 @@ public class PartnerRow : PartnerUpdate public int Id { get; set; } public int? SubscriptionsCount { get; set; } public int? Keys { get; set; } - + /// + /// The names of the partner's adapter properties — never their values, which + /// can be secrets. Names alone are enough to count them in a list and to offer + /// them as {{partner.x}} reference tokens when configuring an adapter. + /// + public ICollection PropertyKeys { get; set; } } public class PartnerUpdate : PartnerCreate diff --git a/SW.Bitween.Sdk/Model/Permissions.cs b/SW.Bitween.Sdk/Model/Permissions.cs new file mode 100644 index 00000000..d5e3cc32 --- /dev/null +++ b/SW.Bitween.Sdk/Model/Permissions.cs @@ -0,0 +1,269 @@ +using System.Collections.Generic; +using System.Linq; + +namespace SW.Bitween.Model; + +/// +/// Every privilege Bitween recognises, as "<area>.<action>" keys. This is the single +/// source of truth: handlers guard on these constants, roles store the keys, and the +/// management UI renders the catalog served by GET /permissions. A unit test asserts +/// these constants and never drift apart. +/// +public static class Permissions +{ + public static class Exchanges + { + public const string View = "exchanges.view"; + public const string Operate = "exchanges.operate"; + } + + public static class Monitoring + { + public const string View = "monitoring.view"; + } + + public static class Dashboard + { + public const string View = "dashboard.view"; + } + + public static class Subscriptions + { + public const string View = "subscriptions.view"; + public const string Create = "subscriptions.create"; + public const string Edit = "subscriptions.edit"; + public const string Delete = "subscriptions.delete"; + public const string Operate = "subscriptions.operate"; + } + + public static class Partners + { + public const string View = "partners.view"; + public const string Create = "partners.create"; + public const string Edit = "partners.edit"; + public const string Delete = "partners.delete"; + } + + public static class Documents + { + public const string View = "documents.view"; + public const string Create = "documents.create"; + public const string Edit = "documents.edit"; + public const string Delete = "documents.delete"; + } + + public static class GlobalValues + { + public const string View = "global-values.view"; + public const string Create = "global-values.create"; + public const string Edit = "global-values.edit"; + public const string Delete = "global-values.delete"; + } + + /// No delete: notifiers have no delete endpoint, so there is nothing to guard. + public static class Notifiers + { + public const string View = "notifiers.view"; + public const string Create = "notifiers.create"; + public const string Edit = "notifiers.edit"; + } + + public static class ApiGateways + { + public const string View = "api-gateways.view"; + public const string Create = "api-gateways.create"; + public const string Edit = "api-gateways.edit"; + public const string Delete = "api-gateways.delete"; + } + + public static class BusGateways + { + public const string View = "bus-gateways.view"; + public const string Create = "bus-gateways.create"; + public const string Edit = "bus-gateways.edit"; + public const string Delete = "bus-gateways.delete"; + } + + public static class WorkGroups + { + public const string View = "workgroups.view"; + public const string Create = "workgroups.create"; + public const string Edit = "workgroups.edit"; + public const string Delete = "workgroups.delete"; + } + + /// + /// No operate: running a scheduled retry now is gated on , + /// because what's being retried is an exchange, not the policy that scheduled it. + /// + public static class RetryPolicies + { + public const string View = "retry-policies.view"; + public const string Create = "retry-policies.create"; + public const string Edit = "retry-policies.edit"; + public const string Delete = "retry-policies.delete"; + } + + public static class Users + { + public const string View = "users.view"; + public const string Create = "users.create"; + public const string Edit = "users.edit"; + public const string Delete = "users.delete"; + } + + public static class Roles + { + public const string View = "roles.view"; + public const string Create = "roles.create"; + public const string Edit = "roles.edit"; + public const string Delete = "roles.delete"; + } + + public static class Settings + { + public const string View = "settings.view"; + public const string Edit = "settings.edit"; + } +} + +public class PermissionActionModel +{ + public string Id { get; set; } + + /// What this specific grant allows, in end-user words. + public string Description { get; set; } +} + +public class PermissionAreaModel +{ + public string Id { get; set; } + public string Label { get; set; } + + /// Mirrors the app's navigation groups, so a role's grants map onto what its members see. + public string Group { get; set; } + + public string Description { get; set; } + public List Actions { get; set; } = []; +} + +public static class PermissionCatalog +{ + public const string View = "view"; + public const string Create = "create"; + public const string Edit = "edit"; + public const string Delete = "delete"; + public const string Operate = "operate"; + + private static PermissionAreaModel Area(string id, string label, string group, string description, + params (string Id, string Description)[] actions) => new() + { + Id = id, + Label = label, + Group = group, + Description = description, + Actions = actions.Select(a => new PermissionActionModel { Id = a.Id, Description = a.Description }).ToList() + }; + + public static readonly List Areas = + [ + // ——— Operate ——— + Area("exchanges", "Exchanges", "Operate", "Every message that flows through Bitween.", + (View, "Browse exchanges, payloads and traces."), + (Operate, "Retry or resubmit failed exchanges.")), + + Area("monitoring", "Queue health", "Operate", "Live message-queue throughput and consumers.", + (View, "See queue health and rates.")), + + Area("dashboard", "Dashboard", "Operate", "Traffic and health overview (reached from the logo).", + (View, "See the dashboard.")), + + // ——— Integrations ——— + Area("subscriptions", "Integrations", "Integrations", "The configured pipelines that process exchanges.", + (View, "Browse integrations and their configuration."), + (Create, "Create integrations."), + (Edit, "Change adapters, mappings and settings."), + (Delete, "Delete integrations."), + (Operate, "Pause, resume, receive now, aggregate now.")), + + Area("partners", "Partners", "Integrations", "The external parties you exchange data with.", + (View, "Browse partners and their properties."), + (Create, "Create partners."), + (Edit, "Change partner details, properties and API keys."), + (Delete, "Delete partners.")), + + Area("documents", "Information types", "Integrations", + "The kinds of business documents that flow between partners.", + (View, "Browse information types."), + (Create, "Create information types."), + (Edit, "Change information types, codes and promoted properties."), + (Delete, "Delete unused information types.")), + + Area("global-values", "Global values", "Integrations", "Shared value sets adapters can reference.", + (View, "Browse global value sets."), + (Create, "Create value sets."), + (Edit, "Change value sets."), + (Delete, "Delete value sets.")), + + Area("notifiers", "Notifiers", "Integrations", "Alerts sent when exchanges fail or succeed.", + (View, "Browse notifiers and their delivery history."), + (Create, "Create notifiers."), + (Edit, "Change notifiers.")), + + Area("api-gateways", "API gateways", "Integrations", "HTTP entry points partners call into.", + (View, "Browse API gateways and attached partners."), + (Create, "Create new API gateways."), + (Edit, "Change gateways and partner attachments."), + (Delete, "Delete API gateways.")), + + Area("bus-gateways", "Bus gateways", "Integrations", "Bus listeners that route documents to integrations.", + (View, "Browse bus gateways and routes."), + (Create, "Create new bus gateways."), + (Edit, "Change gateways and routes."), + (Delete, "Delete bus gateways.")), + + // ——— Configuration ——— + Area("workgroups", "Work groups", "Configuration", "Processing lanes that spread load across queues.", + (View, "See work groups and their throughput."), + (Create, "Create work groups."), + (Edit, "Change work group settings."), + (Delete, "Delete unused work groups.")), + + Area("retry-policies", "Retry policies", "Configuration", "Rules for retrying failed exchanges.", + (View, "Browse retry policies and scheduled retries."), + (Create, "Create retry policies."), + (Edit, "Change retry policies."), + (Delete, "Delete retry policies.")), + + // ——— Administration ——— + Area("users", "Members", "Administration", "The people who can sign in to this Bitween instance.", + (View, "See the member list."), + (Create, "Invite new members."), + (Edit, "Change members' roles, disable accounts, reset passwords."), + (Delete, "Remove members.")), + + Area("roles", "Roles", "Administration", "What each kind of member is allowed to do.", + (View, "See roles and their permissions."), + (Create, "Create roles."), + (Edit, "Change role permissions."), + (Delete, "Delete unassigned roles.")), + + Area("settings", "Settings", "Administration", "Instance-wide configuration.", + (View, "See instance settings."), + (Edit, "Change instance settings.")) + ]; + + /// Every valid permission key. + public static readonly HashSet AllKeys = + Areas.SelectMany(a => a.Actions.Select(x => $"{a.Id}.{x.Id}")).ToHashSet(); + + /// Drops anything not in the catalog — stale keys left over from a removed area. + public static List Sanitize(IEnumerable keys) => + (keys ?? []).Where(AllKeys.Contains).Distinct().ToList(); + + /// Every key in the given navigation groups, optionally view-only. + public static List InGroups(bool viewOnly, params string[] groups) => + Areas.Where(a => groups.Contains(a.Group)) + .SelectMany(a => a.Actions.Where(x => !viewOnly || x.Id == View).Select(x => $"{a.Id}.{x.Id}")) + .ToList(); +} diff --git a/SW.Bitween.Sdk/Model/Roles.cs b/SW.Bitween.Sdk/Model/Roles.cs new file mode 100644 index 00000000..22b5f7f4 --- /dev/null +++ b/SW.Bitween.Sdk/Model/Roles.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace SW.Bitween.Model; + +public class RoleCreate +{ + public required string Name { get; set; } + public string Description { get; set; } + public List Permissions { get; set; } = []; +} + +public class RoleUpdate : RoleCreate; + +/// One shape for both the roles list and a single role — the UI shows the same fields. +public class RoleRow +{ + public int Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + + /// Built-in roles can be assigned, but not edited or deleted. + public bool IsSystem { get; set; } + + public List Permissions { get; set; } = []; + public int MemberCount { get; set; } + public DateTime CreatedOn { get; set; } +} diff --git a/SW.Bitween.Sdk/Model/Settings.cs b/SW.Bitween.Sdk/Model/Settings.cs new file mode 100644 index 00000000..3e69c42a --- /dev/null +++ b/SW.Bitween.Sdk/Model/Settings.cs @@ -0,0 +1,51 @@ +namespace SW.Bitween.Model; + +/// +/// One setting as the settings page sees it: the catalog's definition plus whatever value is in +/// effect. A secret's value never leaves the server — only reveals that +/// one is set. +/// +public class SettingRow +{ + public string Key { get; set; } + public string Section { get; set; } + public string Label { get; set; } + public string Description { get; set; } + + /// "string", "number", "boolean" or "color". + public string Kind { get; set; } + + /// The product default — what a reset returns this setting to. Empty for secrets. + public string DefaultValue { get; set; } + + /// The stored value. Always null for secrets, whose value never leaves the server. + public string Value { get; set; } + + public bool Secret { get; set; } + + /// True when the stored value differs from the product default, i.e. a reset would do something. + public bool Overridden { get; set; } + + /// Whether the effective value is non-empty — lets the UI mask a secret that is set. + public bool HasValue { get; set; } + + /// + /// Whether the UI may write this setting. False for every environment-owned setting, and for a + /// secret on an instance with no Bitween:SettingsEncryptionKey — there's nowhere safe to + /// store that, so it stays configuration-only. + /// + public bool Editable { get; set; } + + /// + /// How to render the row: "editable" (stored, changeable), "readonly" (an + /// environment value, shown but not changeable) or "presence" (an environment value + /// reported only as set or not set). + /// + public string Access { get; set; } +} + +public class SettingUpdate +{ + /// The new value as text; empty clears the setting. Reset-to-default is a DELETE instead. + public string Value { get; set; } +} diff --git a/SW.Bitween.Sdk/Model/Workgroups.cs b/SW.Bitween.Sdk/Model/Workgroups.cs index 969a9935..5a37696a 100644 --- a/SW.Bitween.Sdk/Model/Workgroups.cs +++ b/SW.Bitween.Sdk/Model/Workgroups.cs @@ -26,6 +26,8 @@ public class WorkGroupModel public double? NotifierIncomingRate { get; set; } public long? NotifierProcessingCount { get; set; } public long? NotifierQueueCount { get; set; } + /// Live count of active RabbitMQ consumer instances for this group's queue. + public long? ProcessorNodeCount { get; set; } } public class CreateWorkGroupModel diff --git a/SW.Bitween.UnitTests/PermissionCatalogTests.cs b/SW.Bitween.UnitTests/PermissionCatalogTests.cs new file mode 100644 index 00000000..aa313cbc --- /dev/null +++ b/SW.Bitween.UnitTests/PermissionCatalogTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class PermissionCatalogTests +{ + /// Every key declared as a constant on . + private static List ConstantKeys() => + typeof(Permissions).GetNestedTypes() + .SelectMany(area => area.GetFields(BindingFlags.Public | BindingFlags.Static)) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()) + .ToList(); + + [TestMethod] + public void Constants_AndCatalog_DescribeTheSameKeys() + { + var constants = ConstantKeys(); + + // A constant with no catalog entry can never be granted, so the handler guarding on it + // would deny everyone. A catalog entry with no constant is a grant nothing enforces. + CollectionAssert.AreEquivalent( + PermissionCatalog.AllKeys.ToList(), + constants, + "Permissions constants and PermissionCatalog have drifted apart."); + } + + [TestMethod] + public void EveryKey_IsAreaDotAction() + { + foreach (var key in PermissionCatalog.AllKeys) + Assert.AreEqual(2, key.Split('.').Length, $"'{key}' is not in . form."); + } + + [TestMethod] + public void Constants_HaveNoDuplicates() + { + var constants = ConstantKeys(); + CollectionAssert.AreEquivalent(constants.Distinct().ToList(), constants); + } + + [TestMethod] + public void Administrator_GrantsEveryPermission() + { + // Derived from the catalog rather than stored, so a newly added permission reaches + // administrators without a migration. Guards that behaviour. + CollectionAssert.AreEquivalent( + PermissionCatalog.AllKeys.ToList(), + Role.SystemPermissions(Role.AdministratorId)); + } + + [TestMethod] + public void MemberAndViewer_CannotReachAdministration() + { + var administration = PermissionCatalog.Areas + .Where(a => a.Group == "Administration") + .SelectMany(a => a.Actions.Select(x => $"{a.Id}.{x.Id}")) + .ToList(); + + foreach (var roleId in new[] { Role.MemberId, Role.ViewerId }) + { + var granted = Role.SystemPermissions(roleId); + Assert.IsFalse(granted.Intersect(administration).Any(), + $"Built-in role {roleId} must not grant members/roles/settings access."); + Assert.IsTrue(granted.Count > 0); + } + } + + [TestMethod] + public void Viewer_GrantsViewOnly() + { + Assert.IsTrue(Role.SystemPermissions(Role.ViewerId).All(k => k.EndsWith(".view"))); + } + + [TestMethod] + public void Member_CanWriteIntegrationsButNotOnlyView() + { + var granted = Role.SystemPermissions(Role.MemberId); + Assert.IsTrue(granted.Contains(Permissions.Subscriptions.Edit)); + Assert.IsTrue(granted.Contains(Permissions.Subscriptions.View)); + Assert.IsFalse(granted.Contains(Permissions.Users.View)); + } + + [TestMethod] + public void Sanitize_DropsKeysOutsideTheCatalog() + { + var cleaned = PermissionCatalog.Sanitize([ + Permissions.Partners.View, "partners.teleport", "", Permissions.Partners.View + ]); + + CollectionAssert.AreEqual(new List { Permissions.Partners.View }, cleaned); + } + + [TestMethod] + public void CustomRole_GrantsExactlyWhatItStores() + { + var role = new Role("Support", "Reads exchanges", [Permissions.Exchanges.View, "nope.view"]); + CollectionAssert.AreEqual(new List { Permissions.Exchanges.View }, + role.GetEffectivePermissions()); + } +} diff --git a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs index bb8a11a5..14214390 100644 --- a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs +++ b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs @@ -246,6 +246,31 @@ public void Evaluator_WrongResultType_GroupSkipped() Assert.IsFalse(decision.ShouldRetry); } + [TestMethod] + public void Evaluator_EmptyMatchers_MatchesEveryApplicableFailure() + { + var group = new RetryGroup + { + Name = "catch-all", + Priority = 10, + Enabled = true, + AppliesTo = [XchangeResultType.Error], + Action = RetryAction.Allow, + Matchers = [], + Budget = new RetryBudget + { + MaxAttemptsPerError = 5, + MaxAttemptsTotal = 100, + DelayStrategy = new FixedDelayStrategy { DelayMs = 1000 } + } + }; + var policy = PolicyWith(group); + var ev = new RetryPolicyEvaluator(policy); + var decision = ev.Evaluate(XchangeResultType.Error, "anything at all", 0); + Assert.IsTrue(decision.ShouldRetry); + Assert.AreEqual("catch-all", decision.MatchedGroupName); + } + // ─── Evaluator: priority ordering ─────────────────────────────────────────── [TestMethod] diff --git a/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs b/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs index 6ced9280..2ce3363f 100644 --- a/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs +++ b/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs @@ -83,7 +83,7 @@ public void Parity_PartnerProperty_RendersCorrectly() { const string template = """ { - "pkey": {{ __partner__?.apiKey | json }}, + "pkey": {{ (__partner__ ?? {})["apiKey"] | json }}, } """; const string inputJson = """{"__partner__": {"apiKey": "secret-123"}}"""; @@ -96,13 +96,31 @@ public void Parity_PartnerProperty_MissingPartner_ReturnsNull() { const string template = """ { - "pkey": {{ __partner__?.apiKey | json }}, + "pkey": {{ (__partner__ ?? {})["apiKey"] | json }}, } """; var result = ScribanJsonTestHelper.RenderObject(template, "{}"); Assert.AreEqual(JTokenType.Null, result["pkey"]?.Type); } + // ── Partner property with a key that isn't a valid bare identifier ───────── + // Config: { target: 'pkey', source: '', partnerPropKey: 'gw prop test' } + // Regression: the old `__partner__?.gw prop test` shape wasn't even legal Scriban for + // a key like this. String-indexing works for any key content. + + [TestMethod] + public void Parity_PartnerProperty_KeyWithSpaces_RendersCorrectly() + { + const string template = """ + { + "pkey": {{ (__partner__ ?? {})["gw prop test"] | json }}, + } + """; + const string inputJson = """{"__partner__": {"gw prop test": "test val"}}"""; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + Assert.AreEqual("test val", result["pkey"]?.ToString()); + } + // ── Global set key ───────────────────────────────────────────────────────── // Config: { target: 'gval', source: '', globalSetId: 'mySet', globalKey: 'region' } @@ -111,7 +129,7 @@ public void Parity_GlobalSetKey_RendersCorrectly() { const string template = """ { - "gval": {{ __globals__?.mySet["region"] | json }}, + "gval": {{ ((__globals__ ?? {})["mySet"] ?? {})["region"] | json }}, } """; const string inputJson = """{"__globals__": {"mySet": {"region": "eu-west"}}}"""; @@ -119,6 +137,36 @@ public void Parity_GlobalSetKey_RendersCorrectly() Assert.AreEqual("eu-west", result["gval"]?.ToString()); } + [TestMethod] + public void Parity_GlobalSetKey_MissingGlobals_ReturnsNull() + { + const string template = """ + { + "gval": {{ ((__globals__ ?? {})["mySet"] ?? {})["region"] | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, "{}"); + Assert.AreEqual(JTokenType.Null, result["gval"]?.Type); + } + + // ── Global set id with characters invalid in a bare identifier ───────────── + // Config: { target: 'gval', source: '', globalSetId: 'global-value-name', globalKey: 'baseUrl' } + // Regression: the old `__globals__?.global-value-name["baseUrl"]` shape tokenized the + // hyphens as subtraction and threw "Object `name` is null" — this is the exact bug report. + + [TestMethod] + public void Parity_GlobalSetKey_HyphenatedSetId_RendersCorrectly() + { + const string template = """ + { + "gval": {{ ((__globals__ ?? {})["global-value-name"] ?? {})["baseUrl"] | json }}, + } + """; + const string inputJson = """{"__globals__": {"global-value-name": {"baseUrl": "https://example.test"}}}"""; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + Assert.AreEqual("https://example.test", result["gval"]?.ToString()); + } + // ── Lookup — null fallback ───────────────────────────────────────────────── // Config: { target: 'category', source: 'Cat', lookupDictionary: { entries:[{A→Alpha}], fallback:'null' } } diff --git a/SW.Bitween.UnitTests/SettingsProtectorTests.cs b/SW.Bitween.UnitTests/SettingsProtectorTests.cs new file mode 100644 index 00000000..98a501ef --- /dev/null +++ b/SW.Bitween.UnitTests/SettingsProtectorTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Security.Cryptography; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class SettingsProtectorTests +{ + /// + /// Shaped like a Rebex license key — the longest secret the catalog holds — but deliberately + /// not one. Nothing here depends on the contents, only on the round trip. + /// + private const string Secret = "==NOT-A-REAL-KEY-0123456789abcdefghijklmnop=="; + + private static SettingsProtector With(string passphrase) => + new(new BitweenOptions { SettingsEncryptionKey = passphrase }); + + [TestMethod] + public void Round_trips_a_secret() + { + var protector = With("a-passphrase"); + + Assert.AreEqual(Secret, protector.Unprotect(protector.Protect(Secret))); + } + + [TestMethod] + public void Stored_form_never_contains_the_plaintext() + { + var stored = With("a-passphrase").Protect(Secret); + + Assert.IsFalse(stored.Contains(Secret, StringComparison.Ordinal)); + // The version marker is what tells a stored value apart from a hand-written plain one. + Assert.IsTrue(stored.StartsWith("enc.v1:", StringComparison.Ordinal)); + } + + /// + /// Fresh salt and nonce per value, so two instances holding the same license key don't reveal + /// that by having identical rows. + /// + [TestMethod] + public void Encrypting_the_same_value_twice_gives_different_ciphertext() + { + var protector = With("a-passphrase"); + + Assert.AreNotEqual(protector.Protect(Secret), protector.Protect(Secret)); + } + + // Both of these surface as AuthenticationTagMismatchException (a CryptographicException): + // GCM can't distinguish "wrong key" from "altered bytes", and either way it refuses to + // hand back a plaintext rather than returning garbage. + [TestMethod] + public void A_different_passphrase_cannot_read_it() + { + var stored = With("a-passphrase").Protect(Secret); + + Assert.ThrowsException( + () => With("another-passphrase").Unprotect(stored)); + } + + [TestMethod] + public void Tampering_is_detected_rather_than_decrypted() + { + var protector = With("a-passphrase"); + var stored = protector.Protect(Secret); + // Flip the last ciphertext character — the authentication tag must reject it. + var tampered = stored[..^2] + (stored[^2] == 'A' ? 'B' : 'A') + stored[^1]; + + Assert.ThrowsException(() => protector.Unprotect(tampered)); + } + + /// A value written before a passphrase existed, or edited by hand, still reads back. + [TestMethod] + public void Unmarked_values_pass_through_untouched() + { + Assert.AreEqual("plain-text-value", With("a-passphrase").Unprotect("plain-text-value")); + Assert.AreEqual("", With("a-passphrase").Unprotect("")); + Assert.IsNull(With("a-passphrase").Unprotect(null)); + } + + [TestMethod] + public void Empty_secrets_are_stored_as_empty_not_as_ciphertext() + { + Assert.AreEqual("", With("a-passphrase").Protect("")); + } + + [TestMethod] + public void Without_a_passphrase_nothing_can_be_protected() + { + Assert.IsFalse(With(null).IsConfigured); + Assert.IsFalse(With(" ").IsConfigured); + Assert.ThrowsException(() => With(null).Protect(Secret)); + } +} diff --git a/SW.Bitween.Web/ClientApp/.gitignore b/SW.Bitween.Web/ClientApp/.gitignore new file mode 100644 index 00000000..7324fd27 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Playwright +test-results +playwright-report +blob-report +playwright/.cache + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/SW.Bitween.Web/ClientApp/.oxlintrc.json b/SW.Bitween.Web/ClientApp/.oxlintrc.json new file mode 100644 index 00000000..6fa991da --- /dev/null +++ b/SW.Bitween.Web/ClientApp/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/SW.Bitween.Web/ClientApp/README.md b/SW.Bitween.Web/ClientApp/README.md new file mode 100644 index 00000000..c9a64aea --- /dev/null +++ b/SW.Bitween.Web/ClientApp/README.md @@ -0,0 +1,60 @@ +# Bitween UI — redesign prototype + +Clean-slate redesign of the Bitween admin UI, hosted by `SW.Bitween.Web`. **Runs entirely on +mock data** — it never calls the API, even when served by it. Sub-phase 1 covers auth, dynamic +RBAC, user profile, and team management; other areas exist as permission-gated placeholder +pages so role gating can be demonstrated across the whole navigation. + +## Running it + +**UI-only (fastest loop):** + +```bash +npm install +npm run dev # → http://localhost:5173/ +``` + +**Backend-served (how it deploys):** + +```bash +npm run build # outputs into ../wwwroot with base / +dotnet run --project .. --launch-profile SW.Bitween.Web.Local +# → https://localhost:7155/ +``` + +`dotnet publish` runs the npm build automatically (skip with `-p:SkipClientBuild=true`); +the root `Dockerfile` builds the UI in its own node stage. The app lives under the host's +`/bitween` path base — runtime URLs must use `import.meta.env.BASE_URL` (see the project +instructions in `.github/instructions/`). + +Sign in with any prototype account listed on the login page (password `bitween`), or use the +one-click persona buttons. The floating **Demo** pill (bottom right) switches the signed-in +person at any time and resets the demo data. + +## Stack + +React 19 · TypeScript · Vite · Tailwind v4 · react-router v8 · TanStack Query · lucide-react. + +Brand carried over from the existing UI: the crimson ramp from `Bitween-UI/tailwind.config.js` +(verbatim, as `--color-crimson-*`) and the logo (`public/brand/`). Neutrals (`--color-ink-*`) +are derived from the logo's wordmark ink `#372f2e`. Tokens live in `src/index.css`. + +## Architecture — the parts that matter + +- **`src/api/` is the only data layer.** Components import `api` from `src/api/index.ts`, + which today points at `src/api/mock/mockClient.ts` (a localStorage-backed fake with + simulated latency). Implementing `ApiClient` (`src/api/client.ts`) over HTTP and changing + that one export swaps the whole app onto the real backend. +- **`src/api/permissions.ts` is the permission catalog** — every gated area and action. + Roles are named sets of these keys; a user's permissions are the union of their roles'. +- **`src/nav.ts` is the information architecture.** Sidebar, role-editor access preview and + post-login redirect all derive from it, each filtered by the session's permissions. +- **Gating hides, never dims**: `` for actions, `` for routes + (`src/auth/guards.tsx`). Unauthorized pages get an explanatory access-denied screen. +- **Everything is URL-addressable**: tabs are routes, the member drawer is a route, search + and filters are query params, dialogs are query params. +- Deployment assumptions (to revisit): served at root path, single project, client-side + routing compatible with being backend-served later. + +Anything the prototype needed that the real backend can't do yet is logged in +`Bitween-api/BACKEND_CAPABILITIES_NEEDED.md`. diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts new file mode 100644 index 00000000..2cf8bfae --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test("dashboard loads with real aggregated data", async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); + + await page.goto("dashboard"); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Exchanges today")).toBeVisible(); + await expect(page.getByText("Success rate (7 days)")).toBeVisible(); + await expect(page.getByText("undefined")).toHaveCount(0); + await expect(page.getByText("NaN")).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts new file mode 100644 index 00000000..7ba60137 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { + test.setTimeout(45000); + await page.goto("exchanges"); + await expect(page.getByRole("row", { name: /azureBlob test sub/ }).first()).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("undefined")).toHaveCount(0); + // Avoid the background refetch racing with row selection below. + await page.getByLabel("Refresh interval").selectOption("0"); + + // Filter down to failed exchanges only. + await page.getByRole("button", { name: "Failed" }).click(); + const row = page.getByRole("row", { name: "36b3e2b1003048ff8dec1573b2f752c5" }); + await expect(row).toBeVisible({ timeout: 10000 }); + + // Expand the row (click the chevron cell — other cells stop propagation) + // and retry it from the drawer. + await row.locator("td").last().click(); + await page.getByRole("button", { name: "Retry…" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Retry" }).click(); + await expect(page.getByText(/Retry started/)).toBeVisible({ timeout: 10000 }); + + // Bulk retry a couple of specific rows (not the whole page — each retry does + // real file I/O against storage, so keep this fast and deterministic). + await page.getByRole("button", { name: "All" }).click(); + await expect(page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" })).toBeVisible({ + timeout: 10000, + }); + await page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" }).check(); + await page.getByRole("checkbox", { name: "Select a1b088fffe9e4993ac75736576a826cb" }).check(); + await expect(page.getByText(/\d+ selected/)).toBeVisible(); + await page.getByRole("button", { name: "Retry selected…" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Retry" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0, { timeout: 15000 }); + + // Manually create an exchange addressed at an integration. + await page.goto("exchanges/new"); + await page.getByRole("combobox", { name: "Pick an integration…" }).click(); + await page.getByRole("option", { name: "s3 test sub" }).click(); + // Dismiss the dropdown panel via an outside click (it sits above the panel's + // anchor point, so it can't itself be covered) rather than Escape, which + // doesn't close this Headless UI combobox instance. + await page.getByRole("heading", { name: "New exchange" }).click(); + await expect(page.getByRole("listbox")).toHaveCount(0); + await page.locator("textarea").fill('{"test": true}'); + await page.getByRole("button", { name: "Create exchange" }).click(); + await expect(page).toHaveURL(/\/exchanges\?ids=/); + await expect(page.getByRole("row")).toHaveCount(2, { timeout: 10000 }); // header + the one new row +}); + +test("scheduled retries page loads", async ({ page }) => { + await page.goto("scheduled-retries"); + await expect(page.getByRole("heading", { name: "Scheduled retries" })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("undefined")).toHaveCount(0); +}); + +test("queue health page loads with live consumer data", async ({ page }) => { + await page.goto("queue-health"); + await expect(page.getByRole("heading", { name: "Queue health" })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("v3.local.bitween").first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("undefined")).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts new file mode 100644 index 00000000..da8358d6 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("API gateway: create, attach partner, create integration detour, edit attachment, detach, delete", async ({ + page, +}) => { + const name = `Playwright API GW ${Date.now()}`; + + await page.goto("api-gateways/new"); + await page.fill("#nag-name", name); + await page.getByRole("button", { name: "Create gateway" }).click(); + await expect(page).toHaveURL(/\/api-gateways\/\d+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + + // Attach a partner, detouring to create the required GatewayApiCall integration inline. + await page.getByRole("button", { name: "Attach partner" }).click(); + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); + + await page.getByRole("button", { name: "acme" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + const integrationName = `Playwright GW Integration ${Date.now()}`; + await page.getByRole("link", { name: "New integration" }).click(); + await expect(page).toHaveURL(/\/subscriptions\/new\?type=GatewayApiCall/); + await page.fill("#ni-name", integrationName); + await page.getByRole("button", { name: "test doc" }).click(); + await page.getByLabel("handler adapter").click(); + await page.getByRole("option", { name: "NativeHttpHandler" }).click(); + await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); + await page.locator("#prop-Url").fill("https://example.com/sink"); + await page.getByRole("button", { name: "Create integration" }).click(); + + // This page itself renders a ReturnBanner with a "Continue" button before + // the mutation resolves (inherited from the detour link) — wait for the + // create to actually land on the new integration's own page first, or the + // click races and hits that stale button instead. + await expect(page).toHaveURL(/\/subscriptions\/\d+\?/); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); + await expect(page.getByText(integrationName)).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Attach partner" }).click(); + + await expect(page).toHaveURL(/\/api-gateways\/\d+$/); + await expect(page.getByText("acme").first()).toBeVisible(); + await expect(page.getByText(integrationName)).toBeVisible(); + + // Edit the attachment — exercises the remove-then-add path (backend's + // updatepartner can't mutate a composite-key column in place). + await page.getByRole("button", { name: "Edit attachment for acme" }).click(); + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attachments\/\d+$/); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page).toHaveURL(/\/api-gateways\/\d+$/); + await expect(page.getByText(integrationName)).toBeVisible(); + + // Detach. + await page.getByRole("button", { name: "Detach acme" }).click(); + await page + .getByRole("dialog", { name: "Detach this partner?" }) + .getByRole("button", { name: "Detach partner" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("No partners attached")).toBeVisible(); + + // Delete (no attachments left — exercises the plain path; cascade-delete + // path is covered separately since seed data already has an attached gateway). + await page.getByRole("button", { name: "Delete" }).click(); + await page + .getByRole("dialog", { name: "Delete this API gateway?" }) + .getByRole("button", { name: "Delete gateway" }) + .click(); + // ApiGatewayPage navigates to /api-gateways, which the router redirects to + // the unified integrations list. + await expect(page).toHaveURL(/\/subscriptions\?types=api-gateways$/); +}); + +test("Bus gateway: create, add route with match expression, edit route, remove, delete", async ({ page }) => { + const name = `Playwright Bus GW ${Date.now()}`; + + await page.goto("bus-gateways/new"); + await page.fill("#nbg-name", name); + await page.getByRole("button", { name: "test-hh" }).click(); + await page.getByRole("button", { name: "Create gateway" }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + + await page.getByRole("button", { name: "Add route" }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/); + + // Filter step — leave the match expression empty (null = matches everything). + await page.getByRole("button", { name: "Continue" }).click(); + // Partner step — no partner. + await page.getByRole("button", { name: "No partner" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Integration step — detour to create the required BusGateway integration. + const integrationName = `Playwright Bus Integration ${Date.now()}`; + await page.getByRole("link", { name: "New integration" }).click(); + await expect(page).toHaveURL(/\/subscriptions\/new\?type=BusGateway/); + await page.fill("#ni-name", integrationName); + await page.getByLabel("handler adapter").click(); + await page.getByRole("option", { name: "NativeHttpHandler" }).click(); + await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); + await page.locator("#prop-Url").fill("https://example.com/sink"); + await page.getByRole("button", { name: "Create integration" }).click(); + + // Wait for the create to actually land (see the comment in the API gateway + // test above) before clicking the ReturnBanner's "Continue". + await expect(page).toHaveURL(/\/subscriptions\/\d+\?/); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/); + await expect(page.getByText(integrationName)).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Add route" }).click(); + + await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); + await expect(page.getByText(integrationName)).toBeVisible(); + + // Edit the route (no-op save exercises the round trip of a null match expression). + await page.getByRole("button", { name: /Edit route \d+/ }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\/routes\/\d+$/); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); + + // Remove the route. + await page.getByRole("button", { name: /Remove route \d+/ }).click(); + await page + .getByRole("dialog", { name: "Remove this route?" }) + .getByRole("button", { name: "Remove route" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("No routes")).toBeVisible(); + + await page.getByRole("button", { name: "Delete" }).click(); + await page + .getByRole("dialog", { name: "Delete this bus gateway?" }) + .getByRole("button", { name: "Delete gateway" }) + .click(); + await expect(page).toHaveURL(/\/subscriptions\?types=bus-gateways$/); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts new file mode 100644 index 00000000..d89b59b5 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts @@ -0,0 +1,85 @@ +import { request } from "@playwright/test"; + +/** + * Leaves the database in a known state before the suite runs. + * + * A test that fails part-way skips its own cleanup, and the leftovers aren't harmless: a stray + * account still holding Administrator is enough to make the last-administrator guard pass, which + * strips the seeded admin's role and fails everything after it. So rather than trusting each test + * to tidy up, every run starts by purging its own residue and putting the admin's role back. + */ + +const API = "https://localhost:7155/api"; +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; +/** Configured break-glass credentials — the only way in when the admin has no roles left. */ +const BREAK_GLASS = { username: "1", password: "1" }; +const ADMINISTRATOR_ROLE_ID = 1; + +const TEST_EMAIL = /^pw-.*@example\.test$/; +const TEST_ROLE = /^PW /; +/** The only settings the suite writes to — see the reset below for why this is a list, not "all". */ +const TEST_SETTINGS = ["Theme.PrimaryColor", "Theme.TabTitle", "Theme.CompanyName"]; + +interface Account { + id: number; + email: string; + roles: { id: number; name: string }[] | null; +} + +export default async function purgeTestData() { + const api = await request.newContext({ ignoreHTTPSErrors: true }); + + // Prefer the real admin; fall back to break-glass, which works even with no roles at all. + const login = await api.post(`${API}/accounts/login`, { + data: { Username: ADMIN_EMAIL, Password: ADMIN_PASSWORD }, + }); + let token: string = login.ok() ? (await login.json()).jwt : ""; + + const auth = () => ({ Authorization: `Bearer ${token}` }); + let accounts = await api.get(`${API}/accounts?limit=500`, { headers: auth() }); + + if (!accounts.ok()) { + const su = await api.post(`${API}/login`, { data: BREAK_GLASS }); + if (!su.ok()) + throw new Error( + "Can't reach the API as an administrator or via break-glass — is the local backend running?", + ); + token = (await su.json()).jwt; + accounts = await api.get(`${API}/accounts?limit=500`, { headers: auth() }); + } + + const rows: Account[] = (await accounts.json()).result ?? []; + + // The seeded admin must hold Administrator, or nothing downstream can manage anything. + const admin = rows.find((a) => a.email.toLowerCase() === ADMIN_EMAIL.toLowerCase()); + if (admin && !(admin.roles ?? []).some((r) => r.id === ADMINISTRATOR_ROLE_ID)) { + await api.post(`${API}/accounts/${admin.id}/setRoles`, { + headers: auth(), + data: { roleIds: [ADMINISTRATOR_ROLE_ID] }, + }); + // Re-read as the repaired admin so the purge below runs with full permissions. + const relogin = await api.post(`${API}/accounts/login`, { + data: { Username: ADMIN_EMAIL, Password: ADMIN_PASSWORD }, + }); + if (relogin.ok()) token = (await relogin.json()).jwt; + } + + for (const account of rows.filter((a) => TEST_EMAIL.test(a.email))) + await api.post(`${API}/accounts/${account.id}/remove`, { headers: auth(), data: {} }); + + const roles = await api.get(`${API}/roles?pageSize=500`, { headers: auth() }); + for (const role of ((await roles.json()).result ?? []) as { id: number; name: string }[]) + if (TEST_ROLE.test(role.name)) + await api.delete(`${API}/roles/${role.id}`, { headers: auth() }); + + // Settings tests assert against product defaults, so a value left behind by a failed run would + // make them fail for the wrong reason. Only the keys the tests actually touch are reset: the + // Settings table is now the only home for values like the MSAL ids and the Rebex license key — + // configuration is read once at first boot and ignored after that — so a blanket reset here + // would destroy real configuration with no way to get it back. + for (const key of TEST_SETTINGS) + await api.delete(`${API}/settings/${encodeURIComponent(key)}`, { headers: auth() }); + + await api.dispose(); +} diff --git a/SW.Bitween.Web/ClientApp/e2e/global-values.spec.ts b/SW.Bitween.Web/ClientApp/e2e/global-values.spec.ts new file mode 100644 index 00000000..91137fc6 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/global-values.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("global value set create, edit values, list, delete", async ({ page }) => { + const stamp = Date.now(); + const name = `Playwright Values ${stamp}`; + + await page.goto("global-values"); + await page.getByRole("button", { name: "New value set" }).click(); + await page.fill("#nvs-name", name); + await page.getByRole("button", { name: "Create value set" }).click(); + + await expect(page).toHaveURL(/\/global-values\/[a-z0-9-]+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + + await page.getByRole("button", { name: "Add key" }).click(); + await page.getByLabel("Key 1").fill("baseUrl"); + await page.getByLabel("Value 1").fill("https://example.com"); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("button", { name: "Save changes" })).toHaveCount(0); + + // Reload forces a fresh GET — proves the write actually persisted server-side. + await page.reload(); + await expect(page.getByLabel("Key 1")).toHaveValue("baseUrl"); + await expect(page.getByLabel("Value 1")).toHaveValue("https://example.com"); + + await page.goto("global-values"); + const row = page.getByRole("row", { name: new RegExp(name) }); + await expect(row).toBeVisible(); + await expect(row.locator("td").nth(2)).toHaveText("1"); // Values column + + await row.click(); + await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete value set" }).click(); + await expect(page).toHaveURL(/\/global-values$/); + await expect(page.getByText(name)).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/helpers.ts b/SW.Bitween.Web/ClientApp/e2e/helpers.ts new file mode 100644 index 00000000..cc9bccfa --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/helpers.ts @@ -0,0 +1,93 @@ +import type { Page } from "@playwright/test"; + +/** Checkbox labels carry their description in the accessible name, so anchor at the start. */ +export const startsWith = (text: string) => + new RegExp("^" + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + +export const ADMIN_EMAIL = "admin@Bitween.systems"; +export const ADMIN_PASSWORD = "Mtm@dmin!2"; + +/** Passwords the members these tests create are given. Both clear the 8-character minimum. */ +export const FIRST_PASSWORD = "Pl4ywright!1"; +export const ROTATED_PASSWORD = "R0tated!Pass2"; + +export async function signIn(page: Page, email: string, password: string) { + await page.goto("login"); + await page.fill("#login-email", email); + await page.fill("#login-password", password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +} + +export const signInAsAdmin = (page: Page) => signIn(page, ADMIN_EMAIL, ADMIN_PASSWORD); + +export async function signOut(page: Page) { + // An open drawer lays a backdrop over the sidebar, which would swallow the click. + const drawer = page.getByRole("dialog", { name: "Member details" }); + if (await drawer.count()) { + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "detached" }); + } + await page.getByRole("button", { name: "Account menu" }).click(); + await page.getByRole("button", { name: "Sign out" }).click(); + await page.waitForURL((url) => url.pathname.endsWith("/login"), { timeout: 15000 }); +} + +/** + * Adds a member through the UI and returns their email. Unique per run so the suite can be + * re-run against the same database without colliding on the unique email index. + */ +export async function addMember( + page: Page, + { name, roles, password = FIRST_PASSWORD }: { name: string; roles: string[]; password?: string }, +) { + const email = `pw-${name.toLowerCase().replace(/\W+/g, "-")}-${Date.now()}@example.test`; + + await page.goto("team/members"); + await page.getByRole("button", { name: "Add member" }).click(); + await page.fill("#member-name", name); + await page.fill("#member-email", email); + await page.fill("#member-password", password); + for (const role of roles) await page.getByRole("checkbox", { name: startsWith(role) }).check(); + await page.getByRole("button", { name: "Add member" }).last().click(); + + await page.waitForSelector(`text=${email}`, { timeout: 15000 }); + return email; +} + +/** Creates a role holding exactly the given permissions. Returns its name. */ +export async function createRole( + page: Page, + { name, permissions }: { name: string; permissions: { area: string; action: string }[] }, +) { + await page.goto("team/roles/new"); + await page.fill("#role-name", name); + await page.fill("#role-desc", "Created by the Playwright suite."); + for (const { area, action } of permissions) + await page.getByRole("checkbox", { name: `${area}: ${action}`, exact: true }).check(); + await page.getByRole("button", { name: "Create role" }).click(); + await page.waitForURL(/\/team\/roles$/, { timeout: 15000 }); + return name; +} + +/** Opens a member's drawer from the members list. */ +export async function openMember(page: Page, email: string) { + await page.goto("team/members"); + await page.getByRole("row", { name: new RegExp(email) }).click(); + await page.getByRole("dialog", { name: "Member details" }).waitFor(); +} + +export async function removeMember(page: Page, email: string) { + await openMember(page, email); + await page.getByRole("button", { name: "Remove from team" }).click(); + await page.getByRole("button", { name: "Remove member" }).click(); + await page.getByRole("dialog", { name: "Member details" }).waitFor({ state: "detached" }); +} + +export async function deleteRole(page: Page, name: string) { + await page.goto("team/roles"); + await page.getByRole("link", { name: new RegExp(name) }).click(); + await page.getByRole("button", { name: "Delete role" }).click(); + await page.getByRole("button", { name: "Delete role" }).last().click(); + await page.waitForURL(/\/team\/roles$/, { timeout: 15000 }); +} diff --git a/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts b/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts new file mode 100644 index 00000000..25814e13 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("information type Code is optional end to end", async ({ page }) => { + const name = `Playwright No Code ${Date.now()}`; + + await page.goto("information-types/new"); + await page.fill("#nit-name", name); + + // Expand the collapsed code/format section and clear the auto-suggested code. + await page.getByRole("button", { name: /^Code/ }).click(); + const codeInput = page.locator("#nit-code"); + await expect(codeInput).not.toHaveAttribute("required", ""); + await codeInput.fill(""); + + await page.getByRole("button", { name: "Create information type" }).click(); + + // Should navigate straight to the detail page — no validation block on empty code. + await expect(page).toHaveURL(/\/information-types\/\d+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + await expect(page.locator("#it-code")).toHaveValue(""); + // The identity badge next to the heading falls back to the name when there's no code. + await expect(page.getByRole("heading", { name }).locator("code")).toHaveText(name); + + // The list page's Code column falls back to the name too, not a blank/dash. + await page.goto("information-types"); + const row = page.getByRole("row", { name: new RegExp(name) }); + await expect(row.locator("code")).toHaveText(name); + + // Cleanup. + await row.click(); + await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete information type" }).click(); + await expect(page).toHaveURL(/\/information-types$/); + await expect(page.getByText(name)).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/integrations.spec.ts b/SW.Bitween.Web/ClientApp/e2e/integrations.spec.ts new file mode 100644 index 00000000..d680e71a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/integrations.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("scheduled job create, adapters, pause/resume, receive now, list, delete", async ({ page }) => { + const name = `Playwright Job ${Date.now()}`; + + await page.goto("scheduled-jobs/new"); + await page.fill("#sjw-name", name); + await page.getByRole("button", { name: "order" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Source & schedule step — receiver adapter + its one required prop. + await page.getByLabel("receiver adapter").click(); + await page.getByRole("option", { name: "NativeHttpReceiver" }).click(); + await page.keyboard.press("Escape"); // close the combobox popover, it doesn't auto-dismiss + await expect(page.getByLabel("receiver adapter")).toHaveValue("NativeHttpReceiver"); + await page.locator("#prop-Url").fill("https://example.com/feed"); + await page.getByRole("button", { name: "Continue" }).click(); + + // Pipeline step — handler adapter + its required prop (mapper stays "None"). + await page.getByLabel("handler adapter").click(); + await page.getByRole("option", { name: "NativeHttpHandler" }).click(); + await expect(page.getByLabel("handler adapter")).toHaveValue("NativeHttpHandler"); + await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); + await page.locator("#prop-Url").fill("https://example.com/sink"); + await page.getByRole("button", { name: "Continue" }).click(); + + // Review step — "Enable immediately" is checked by default. + await page.getByRole("button", { name: "Create scheduled job" }).click(); + + await expect(page).toHaveURL(/\/subscriptions\/\d+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + await expect(page.getByRole("button", { name: "Active" })).toBeVisible(); + + // Pause / resume. + await page.getByRole("button", { name: "Pause" }).click(); + await page.getByRole("dialog", { name: "Pause this integration?" }).getByRole("button", { name: "Pause" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Paused", { exact: true }).first()).toBeVisible(); + + await page.getByRole("button", { name: "Resume" }).click(); + await page.getByRole("dialog", { name: "Resume this integration?" }).getByRole("button", { name: "Resume" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Paused", { exact: true })).toHaveCount(0); + + // Receive now. + await page.getByRole("button", { name: "Receive now" }).click(); + await page.getByRole("dialog", { name: "Receive now?" }).getByRole("button", { name: "Receive now" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Next run")).toBeVisible(); + + // Reload to prove the adapter config truly persisted server-side. + await page.reload(); + await expect(page.getByLabel("receiver adapter")).toHaveValue("NativeHttpReceiver"); + await expect(page.locator("#prop-Url").first()).toHaveValue("https://example.com/feed"); + await expect(page.getByLabel("handler adapter")).toHaveValue("NativeHttpHandler"); + + await page.goto("subscriptions?types=scheduled-jobs"); + const row = page.getByRole("row", { name: new RegExp(name) }); + await expect(row).toBeVisible({ timeout: 15000 }); + await expect(row.getByText("undefined")).toHaveCount(0); + + await row.getByRole("button", { name: `Open ${name}` }).click(); + await expect(page).toHaveURL(/\/subscriptions\/\d+$/); + await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete integration" }).click(); + await expect(page).toHaveURL(/\/subscriptions$/); + await expect(page.getByText(name)).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/login.spec.ts b/SW.Bitween.Web/ClientApp/e2e/login.spec.ts new file mode 100644 index 00000000..4ce0863a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/login.spec.ts @@ -0,0 +1,48 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * What the sign-in page offers is driven by the anonymous config endpoint. These tests rewrite + * that response rather than saving the real setting: `Bitween.DisableEmailPasswordLogin` lives in + * the database, and a test that flipped it on and then failed would leave this instance reachable + * only through Microsoft — which the local profile has no MSAL app for. That's a locked door with + * no key, so the flag is exercised at the boundary instead. + */ +async function withConfig(page: Page, overrides: Record) { + await page.route("**/api/settings/config", async (route) => { + const real = await route.fetch(); + const body = await real.json(); + await route.fulfill({ json: { ...body, ...overrides } }); + }); +} + +const passwordField = (page: Page) => page.locator("#login-password"); +const microsoftButton = (page: Page) => page.getByRole("button", { name: "Continue with Microsoft" }); + +test("by default the sign-in page asks for an email and password", async ({ page }) => { + await page.goto("login"); + + await expect(passwordField(page)).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); +}); + +test("Microsoft-only hides the password form instead of letting it fail", async ({ page }) => { + await withConfig(page, { disableEmailPasswordLogin: true, msalClientId: "00000000-0000-0000-0000-000000000000" }); + await page.goto("login"); + + // The backend rejects email/password outright in this mode, so the form must not be offered. + await expect(microsoftButton(page)).toBeVisible(); + await expect(passwordField(page)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Sign in" })).toHaveCount(0); + // With nothing above it, the divider has nothing to divide. + await expect(page.getByText("or", { exact: true })).toHaveCount(0); +}); + +test("Microsoft-only with no Microsoft app configured explains itself", async ({ page }) => { + await withConfig(page, { disableEmailPasswordLogin: true, msalClientId: null }); + await page.goto("login"); + + // Both doors are shut. Saying so beats an empty card that looks like a failed page load. + await expect(page.getByText(/Microsoft sign-in isn't configured/)).toBeVisible(); + await expect(passwordField(page)).toHaveCount(0); + await expect(microsoftButton(page)).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts new file mode 100644 index 00000000..b2e29b85 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts @@ -0,0 +1,186 @@ +import { test, expect, type Page } from "@playwright/test"; +import { + FIRST_PASSWORD, + addMember, + createRole, + deleteRole, + removeMember, + signIn, + signInAsAdmin, + signOut, +} from "./helpers"; + +/** + * What a role grants has to hold in three places at once: the pages offered in the sidebar, the + * page reached by typing its URL, and the API behind the button. A permission system that only + * hides UI isn't one, so every check here ends at the server. + */ + +const sidebarLinks = (page: Page) => + page.getByRole("navigation").getByRole("link").filter({ hasNotText: /^$/ }); + +/** Calls the API directly with the signed-in member's own token — no UI in the way. */ +async function apiStatus( + page: Page, + method: "GET" | "POST", + path: string, + body?: unknown, +): Promise { + const token = await page.evaluate(() => localStorage.getItem("access_token")); + const res = await page.request.fetch(`https://localhost:7155/api${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { data: body }), + }); + return res.status(); +} + +test("a custom role grants exactly what was ticked, in the nav, by URL, and at the API", async ({ + page, +}) => { + const roleName = `PW Exchange Watcher ${Date.now()}`; + await signInAsAdmin(page); + await createRole(page, { + name: roleName, + permissions: [{ area: "Exchanges", action: "View" }], + }); + const email = await addMember(page, { name: "Watcher Only", roles: [roleName] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + // 1. The sidebar offers the one page they can see, and nothing else. + await expect(sidebarLinks(page).filter({ hasText: "Exchanges" })).toBeVisible(); + for (const hidden of ["Partners", "Integrations", "Work groups", "Team", "Settings"]) + await expect(sidebarLinks(page).filter({ hasText: hidden })).toHaveCount(0); + + // 2. Typing the URL of a page they lack doesn't get them in. + await page.goto("partners"); + await expect(page.getByText("You don't have access to this page")).toBeVisible(); + + await page.goto("team/members"); + await expect(page.getByText("You don't have access to this page")).toBeVisible(); + + // 3. The page they do have loads, and offers no write actions. + await page.goto("exchanges"); + await expect(page.getByText("You don't have access to this page")).toHaveCount(0); + await expect(page.getByRole("button", { name: /^Retry/ })).toHaveCount(0); + + // 4. And the API refuses the same things, so a crafted request gains nothing. + expect(await apiStatus(page, "POST", "/partners", { name: "sneaky" })).toBe(401); + expect(await apiStatus(page, "GET", "/accounts?limit=5")).toBe(401); + expect(await apiStatus(page, "POST", "/roles", { name: "x", description: "", permissions: [] })).toBe(401); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); + await deleteRole(page, roleName); +}); + +test("Viewer can read but not write", async ({ page }) => { + await signInAsAdmin(page); + const email = await addMember(page, { name: "Read Only", roles: ["Viewer"] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + // Reading the configuration pages is fine. + await page.goto("partners"); + await expect(page.getByText("You don't have access to this page")).toHaveCount(0); + await expect(page.getByRole("button", { name: "New partner" })).toHaveCount(0); + + await page.goto("retry-policies"); + await expect(page.getByText("You don't have access to this page")).toHaveCount(0); + await expect(page.getByRole("button", { name: /^New retry policy/ })).toHaveCount(0); + + // Administration is out of reach entirely. + await expect(sidebarLinks(page).filter({ hasText: "Team" })).toHaveCount(0); + await page.goto("settings"); + await expect(page.getByText("You don't have access to this page")).toBeVisible(); + + // Writes are refused at the source, not just hidden. + expect(await apiStatus(page, "POST", "/partners", { name: "nope" })).toBe(401); + expect(await apiStatus(page, "POST", "/retrypolicies", { name: "nope" })).toBe(401); + // Work groups had no guard of any kind until the handlers were given one. + expect(await apiStatus(page, "POST", "/workgroups", { name: "nope", busMessageName: "nope" })).toBe(401); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); +}); + +test("Member can configure integrations but not manage the team", async ({ page }) => { + await signInAsAdmin(page); + const email = await addMember(page, { name: "Regular Member", roles: ["Member"] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + await page.goto("partners"); + await expect(page.getByRole("button", { name: "New partner" })).toBeVisible(); + + // The whole Administration group is absent for a Member. + await expect(sidebarLinks(page).filter({ hasText: "Team" })).toHaveCount(0); + await expect(sidebarLinks(page).filter({ hasText: "Settings" })).toHaveCount(0); + + expect(await apiStatus(page, "GET", "/accounts?limit=5")).toBe(401); + expect(await apiStatus(page, "POST", "/roles", { name: "x", description: "", permissions: [] })).toBe(401); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); +}); + +test("editing a role changes what its members can do, without them signing in again", async ({ + page, +}) => { + const roleName = `PW Growing ${Date.now()}`; + await signInAsAdmin(page); + await createRole(page, { name: roleName, permissions: [{ area: "Exchanges", action: "View" }] }); + const email = await addMember(page, { name: "Gains Access", roles: [roleName] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + await expect(sidebarLinks(page).filter({ hasText: "Partners" })).toHaveCount(0); + + // Grant Partners while they're signed in. Permissions are resolved per request from the + // database rather than baked into the token, so this must take effect without a new login. + const admin = await page.context().browser()!.newContext({ ignoreHTTPSErrors: true }); + const adminPage = await admin.newPage(); + await signInAsAdmin(adminPage); + await adminPage.goto("team/roles"); + await adminPage.getByRole("link", { name: new RegExp(roleName) }).click(); + await adminPage.getByRole("checkbox", { name: "Partners: View", exact: true }).check(); + await adminPage.getByRole("button", { name: "Save changes" }).click(); + await adminPage.waitForURL(/\/team\/roles$/); + + await page.reload(); + await expect(sidebarLinks(page).filter({ hasText: "Partners" })).toBeVisible(); + await page.goto("partners"); + await expect(page.getByText("You don't have access to this page")).toHaveCount(0); + + await admin.close(); + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); + await deleteRole(page, roleName); +}); + +test("a member with no roles at all sees nothing and can do nothing", async ({ page }) => { + await signInAsAdmin(page); + const email = await addMember(page, { name: "No Roles", roles: [] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + await expect(sidebarLinks(page)).toHaveCount(0); + for (const path of ["exchanges", "partners", "team/members", "settings"]) { + await page.goto(path); + await expect(page.getByText("You don't have access to this page")).toBeVisible(); + } + expect(await apiStatus(page, "POST", "/partners", { name: "nope" })).toBe(401); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/retry-policies.spec.ts b/SW.Bitween.Web/ClientApp/e2e/retry-policies.spec.ts new file mode 100644 index 00000000..76bcf1d9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/retry-policies.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("retry policy create, add group with fixed delay, dry-run, list, delete", async ({ page }) => { + const name = `Playwright Policy ${Date.now()}`; + const groupName = "Timeouts"; + + await page.goto("retry-policies"); + await page.getByRole("button", { name: "New retry policy" }).click(); + await page.fill("#nrp-name", name); + await page.getByRole("button", { name: "Create policy" }).click(); + + await expect(page).toHaveURL(/\/retry-policies\/\d+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + await expect(page.getByText("No groups yet")).toBeVisible(); + + await page.getByRole("button", { name: "Add group" }).click(); + const dialog = page.getByRole("dialog", { name: "New group" }); + await dialog.locator("#rg-name").fill(groupName); + await dialog.getByRole("button", { name: "Add condition" }).click(); + await dialog.getByLabel("Text to find").fill("timeout"); + // Fixed delay in seconds — the backend stores this in milliseconds, so this + // exercises the ms <-> seconds conversion in both directions once reloaded. + await dialog.locator("#rg-delay").selectOption("fixed"); + await dialog.locator("#rg-d1").fill("45"); + await dialog.getByRole("button", { name: "Add group" }).click(); + + await expect(page.getByText(groupName)).toBeVisible(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("button", { name: "Save changes" })).toHaveCount(0); + + // Reload forces a fresh GET — proves the group (incl. the delay unit + // conversion) actually round-tripped through the backend correctly. + await page.reload(); + await expect(page.getByText(groupName)).toBeVisible(); + await page.getByRole("button", { name: `Edit ${groupName}` }).click(); + await expect(page.locator("#rg-delay")).toHaveValue("fixed"); + await expect(page.locator("#rg-d1")).toHaveValue("45"); + await page.getByRole("button", { name: "Cancel" }).click(); + + // Dry-run against the saved (now unsaved-clean) groups. + await page.fill("#tp-content", "System.Net.Http.HttpRequestException: timeout while connecting"); + await page.getByRole("button", { name: "Run simulation" }).click(); + const attempt = page.locator("ol li").first(); + await expect(attempt).toContainText("Retries"); + await expect(attempt).toContainText("Next try in 45s"); + + await page.goto("retry-policies"); + const row = page.getByRole("row", { name: new RegExp(name) }); + await expect(row).toBeVisible(); + await expect(row.locator("td").nth(1)).toHaveText("1"); // Groups column + + await row.click(); + await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete policy" }).click(); + await expect(page).toHaveURL(/\/retry-policies$/); + await expect(page.getByText(name)).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts new file mode 100644 index 00000000..73b8461a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts @@ -0,0 +1,177 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signInAsAdmin, signOut, startsWith } from "./helpers"; + +const TEAL = "#0f766e"; +/** A second colour, so the sign-in test stands on its own if the one above left residue. */ +const INDIGO = "#4338ca"; +const DEFAULT_COLOR = "#e3311d"; +const DEFAULT_CRON = "0 * * * * ?"; + +const brandColorVar = (page: Page) => + page.evaluate(() => document.documentElement.style.getPropertyValue("--color-crimson-600").trim()); + +/** The hex box beside the colour swatch; `exact` keeps it apart from the picker itself. */ +const hexInput = (page: Page) => page.getByRole("textbox", { name: "Primary color", exact: true }); + +async function openBrandSection(page: Page) { + await page.goto("settings"); + await page.getByRole("button", { name: "Brand & theme" }).click(); +} + +test.beforeEach(async ({ page }) => { + await signInAsAdmin(page); +}); + +test("sections come from the backend catalog, with no restart-required rows", async ({ page }) => { + await page.goto("settings"); + + for (const section of [ + "Documents & storage", + "API behavior", + "Single sign-on (Microsoft)", + "Adapters", + "Reliability & jobs", + "Messaging", + "Database", + "Security", + "Brand & theme", + ]) + await expect(page.getByRole("button", { name: section })).toBeVisible(); + + // Nothing carries a restart badge: a setting that couldn't take effect immediately is shown + // as an environment value instead of being offered as an edit that needs a restart to land. + await expect(page.getByText("Restart", { exact: true })).toHaveCount(0); +}); + +test("environment settings are shown but not offered as edits", async ({ page }) => { + await page.goto("settings"); + await page.getByRole("button", { name: "Database" }).click(); + + // A read-only row renders its value as text — there's no control carrying its label… + await expect(page.getByText("Use Azure managed identity")).toBeVisible(); + await expect(page.getByText("Off", { exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Use Azure managed identity", exact: true }), + ).toHaveCount(0); + await expect(page.getByRole("checkbox")).toHaveCount(0); + + // …and a presence row reports only whether a value is set, never the value itself. + await expect(page.getByText("Not set", { exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Managed identity client ID", exact: true }), + ).toHaveCount(0); + + // Neither kind can be reset, because neither is stored. + await expect(page.getByRole("button", { name: "Reset to default" })).toHaveCount(0); + await expect(page.getByText("Environment").first()).toBeVisible(); +}); + +test("Microsoft-only sign-in is an editable setting, not an environment value", async ({ page }) => { + await page.goto("settings"); + await page.getByRole("button", { name: "Single sign-on (Microsoft)" }).click(); + + // It applies per request — the Login handler and the config endpoint both read it live — so it + // belongs in the catalog as an edit rather than a read-only environment row. + const toggle = page.getByRole("checkbox", { name: startsWith("Off") }); + await expect(toggle).toBeVisible(); + await expect(toggle).not.toBeChecked(); + await expect(page.getByText("Microsoft sign-in only")).toBeVisible(); +}); + +test("the retry schedule is editable and rejects an invalid cron", async ({ page }) => { + await page.goto("settings"); + await page.getByRole("button", { name: "Reliability & jobs" }).click(); + + const cron = page.getByRole("textbox", { name: "Retry poll schedule", exact: true }); + await expect(cron).toHaveValue(DEFAULT_CRON); + + // The backend validates the expression before storing it, because a bad one would break the + // startup job seeding — so a rejected save leaves the draft dirty rather than silently passing. + await cron.fill("not a cron"); + await cron.blur(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText(/not a valid cron expression/)).toBeVisible(); + + await page.getByRole("button", { name: "Discard" }).click(); + await expect(cron).toHaveValue(DEFAULT_CRON); +}); + +test("brand colour: staged draft previews app-wide, saves, and resets", async ({ page }) => { + await openBrandSection(page); + const hex = hexInput(page); + await expect(hex).toHaveValue(DEFAULT_COLOR); + + await hex.fill(TEAL); + await hex.blur(); + await expect(page.getByText("Unsaved", { exact: true })).toBeVisible(); + // The draft previews immediately, before anything is saved. + expect(await brandColorVar(page)).toBe(TEAL); + + // ...and keeps previewing on other pages, with the banner offering a way back. Navigating + // in-app (rather than a hard load) is what a user does, and what lets the banner name the + // section holding the change. + await page.getByRole("link", { name: "Exchanges" }).click(); + await expect(page.getByText(/Previewing 1 unsaved setting change/)).toBeVisible(); + expect(await brandColorVar(page)).toBe(TEAL); + + await page.getByRole("button", { name: /Continue editing/ }).click(); + await expect(page).toHaveURL(/section=Brand/); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Unsaved", { exact: true })).toHaveCount(0); + + // A reload proves it persisted server-side rather than living in the draft. + await page.reload(); + await expect(hexInput(page)).toHaveValue(TEAL); + expect(await brandColorVar(page)).toBe(TEAL); + + await page.getByRole("button", { name: "Reset to default" }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await page.reload(); + await expect(hexInput(page)).toHaveValue(DEFAULT_COLOR); +}); + +test("a secret's value never reaches the browser", async ({ page }) => { + const payloads: string[] = []; + page.on("response", async (res) => { + if (res.url().endsWith("/api/settings")) payloads.push(await res.text()); + }); + + await page.goto("settings"); + await page.getByRole("button", { name: "Adapters" }).click(); + + // The local backend configures a Rebex key, so the row shows as set — masked, with the + // adapter-config "Replace" affordance rather than the value itself. + await expect(page.getByText("••••••••")).toBeVisible(); + await expect(page.getByRole("button", { name: "Replace" })).toBeVisible(); + + expect(payloads.length).toBeGreaterThan(0); + const rebex = JSON.parse(payloads[0]).find( + (r: { key: string }) => r.key === "Bitween.RebexLicenseKey", + ); + expect(rebex.secret).toBe(true); + expect(rebex.value).toBeNull(); + expect(rebex.defaultValue).toBe(""); + expect(rebex.hasValue).toBe(true); + // Editable because this instance has an encryption key configured; without one the row comes + // back read-only instead. + expect(rebex.editable).toBe(true); +}); + +test("the sign-in page brands itself before anyone has signed in", async ({ page }) => { + await openBrandSection(page); + await hexInput(page).fill(INDIGO); + await hexInput(page).blur(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Unsaved", { exact: true })).toHaveCount(0); + + await signOut(page); + // No session here at all — the login page reads branding from the anonymous config endpoint. + await expect.poll(() => brandColorVar(page)).toBe(INDIGO); + + await signInAsAdmin(page); + await openBrandSection(page); + await page.getByRole("button", { name: "Reset to default" }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await page.reload(); + await expect(hexInput(page)).toHaveValue(DEFAULT_COLOR); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts new file mode 100644 index 00000000..c3d93435 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts @@ -0,0 +1,179 @@ +import { test, expect } from "@playwright/test"; +import { + ADMIN_EMAIL, + startsWith, + FIRST_PASSWORD, + ROTATED_PASSWORD, + addMember, + openMember, + removeMember, + signIn, + signInAsAdmin, + signOut, +} from "./helpers"; + +test.beforeEach(async ({ page }) => { + await signInAsAdmin(page); +}); + +test("add a member, they can sign in, then remove them", async ({ page }) => { + const email = await addMember(page, { name: "New Joiner", roles: ["Viewer"] }); + + const row = page.getByRole("row", { name: new RegExp(email) }); + await expect(row).toBeVisible(); + await expect(row).toContainText("Viewer"); + await expect(row).toContainText("Active"); + + // The account is real from the first moment — no accept step in between. + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + await expect(page.getByRole("button", { name: "Account menu" })).toContainText("New Joiner"); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); + await expect(page.getByText(email)).toHaveCount(0); +}); + +test("change which roles a member holds", async ({ page }) => { + const email = await addMember(page, { name: "Role Swap", roles: ["Viewer"] }); + + await openMember(page, email); + const drawer = page.getByRole("dialog", { name: "Member details" }); + await drawer.getByRole("checkbox", { name: startsWith("Viewer") }).uncheck(); + await drawer.getByRole("checkbox", { name: startsWith("Member") }).check(); + await drawer.getByRole("button", { name: "Save roles" }).click(); + await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); + + // Reload rather than trust the optimistic UI — proves the write reached the database. + await page.reload(); + const row = page.getByRole("row", { name: new RegExp(email) }); + await expect(row).toContainText("Member"); + await expect(row).not.toContainText("Viewer"); + + await removeMember(page, email); +}); + +test("an administrator resets a member's password", async ({ page }) => { + const email = await addMember(page, { name: "Forgot Pass", roles: ["Viewer"] }); + + await openMember(page, email); + const drawer = page.getByRole("dialog", { name: "Member details" }); + await drawer.getByLabel("New password").fill(ROTATED_PASSWORD); + await drawer.getByRole("button", { name: "Set password" }).click(); + await expect(drawer.getByLabel("New password")).toHaveValue(""); + + await signOut(page); + await signIn(page, email, ROTATED_PASSWORD); + await expect(page.getByRole("button", { name: "Account menu" })).toContainText("Forgot Pass"); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); +}); + +test("the old password stops working after a reset", async ({ page }) => { + const email = await addMember(page, { name: "Stale Pass", roles: ["Viewer"] }); + + await openMember(page, email); + const drawer = page.getByRole("dialog", { name: "Member details" }); + await drawer.getByLabel("New password").fill(ROTATED_PASSWORD); + await drawer.getByRole("button", { name: "Set password" }).click(); + await expect(drawer.getByLabel("New password")).toHaveValue(""); + + await signOut(page); + await page.fill("#login-email", email); + await page.fill("#login-password", FIRST_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page).toHaveURL(/\/login$/); + + await signInAsAdmin(page); + await removeMember(page, email); +}); + +test("disable a member, then re-enable them", async ({ page }) => { + const email = await addMember(page, { name: "On Leave", roles: ["Viewer"] }); + + await openMember(page, email); + const drawer = page.getByRole("dialog", { name: "Member details" }); + await drawer.getByRole("button", { name: "Disable account" }).click(); + await expect(drawer.getByRole("button", { name: "Re-enable account" })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole("row", { name: new RegExp(email) })).toContainText("Disabled"); + + // A disabled account keeps its roles and history but must not be able to sign in. + await signOut(page); + await page.fill("#login-email", email); + await page.fill("#login-password", FIRST_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page).toHaveURL(/\/login$/); + + await signInAsAdmin(page); + await openMember(page, email); + await drawer.getByRole("button", { name: "Re-enable account" }).click(); + await expect(drawer.getByRole("button", { name: "Disable account" })).toBeVisible(); + + await removeMember(page, email); +}); + +test("the last administrator can't be removed or disabled", async ({ page }) => { + // This test only means anything while the seeded admin is the *only* administrator, and it's + // the one test that could strip its own role if the guard didn't fire. So assert the + // precondition rather than assume it, and put the role back if the save somehow goes through. + await page.goto("team/members"); + const admins = page.getByRole("row", { name: /Administrator/ }); + await expect( + admins, + "another account holds Administrator — the guard under test can't fire", + ).toHaveCount(1); + + await openMember(page, ADMIN_EMAIL); + const drawer = page.getByRole("dialog", { name: "Member details" }); + const role = drawer.getByRole("checkbox", { name: startsWith("Administrator") }); + + // Nothing destructive is even offered on your own account. + await expect(drawer.getByRole("button", { name: "Remove from team" })).toHaveCount(0); + await expect(drawer.getByRole("button", { name: "Disable account" })).toHaveCount(0); + + // Dropping the role is offered, but the server refuses it. + await role.uncheck(); + await drawer.getByRole("button", { name: "Save roles" }).click(); + + try { + await expect(drawer.getByText(/only member with the Administrator role/i)).toBeVisible(); + await page.reload(); + await expect(page.getByRole("row", { name: new RegExp(ADMIN_EMAIL) })).toContainText( + "Administrator", + ); + } finally { + // Belt and braces: if the guard let it through, put the role back before failing, so the + // rest of the suite doesn't run against an instance nobody can administer. Read the list + // fresh — the unchecked box in the drawer is a rejected draft, not what the server holds. + await page.goto("team/members"); + const adminRow = page.getByRole("row", { name: new RegExp(ADMIN_EMAIL) }); + if (!((await adminRow.textContent()) ?? "").includes("Administrator")) { + await adminRow.click(); + await drawer.getByRole("checkbox", { name: startsWith("Administrator") }).check(); + await drawer.getByRole("button", { name: "Save roles" }).click(); + await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); + } + } +}); + +test("filter and search the member list", async ({ page }) => { + const email = await addMember(page, { name: "Findable Person", roles: ["Viewer"] }); + + await page.getByLabel("Search members").fill("Findable"); + await expect(page.getByRole("row", { name: new RegExp(email) })).toBeVisible(); + await expect(page.getByRole("row", { name: new RegExp(ADMIN_EMAIL) })).toHaveCount(0); + + await page.getByLabel("Search members").fill(""); + await page.getByRole("button", { name: "Disabled", exact: true }).click(); + await expect(page.getByRole("row", { name: new RegExp(email) })).toHaveCount(0); + + await page.getByRole("button", { name: "Active", exact: true }).click(); + await expect(page.getByRole("row", { name: new RegExp(email) })).toBeVisible(); + + await removeMember(page, email); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts b/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts new file mode 100644 index 00000000..75845a52 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts @@ -0,0 +1,143 @@ +import { test, expect } from "@playwright/test"; +import { addMember, createRole, deleteRole, removeMember, signInAsAdmin } from "./helpers"; + +test.beforeEach(async ({ page }) => { + await signInAsAdmin(page); +}); + +test("create a custom role, then delete it", async ({ page }) => { + const name = `PW Operator ${Date.now()}`; + + await createRole(page, { + name, + permissions: [ + { area: "Exchanges", action: "View" }, + { area: "Exchanges", action: "Operate" }, + ], + }); + + const row = page.getByRole("link", { name: new RegExp(name) }); + await expect(row).toBeVisible(); + await expect(row).toContainText("0 members"); + await expect(row).toContainText("2/"); + + // Reopen it: the permissions must come back from the server exactly as ticked. + await row.click(); + await expect(page.getByRole("checkbox", { name: "Exchanges: View", exact: true })).toBeChecked(); + await expect(page.getByRole("checkbox", { name: "Exchanges: Operate", exact: true })).toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Partners: View", exact: true }), + ).not.toBeChecked(); + + await page.goto("team/roles"); + await deleteRole(page, name); + await expect(page.getByText(name)).toHaveCount(0); +}); + +test("granting an action implies View, and clearing View clears the row", async ({ page }) => { + await page.goto("team/roles/new"); + + // An action you can't view is an action you can't reach, so View comes along. + const view = page.getByRole("checkbox", { name: "Partners: View", exact: true }); + const edit = page.getByRole("checkbox", { name: "Partners: Edit", exact: true }); + const del = page.getByRole("checkbox", { name: "Partners: Delete", exact: true }); + + // Only the count granted is asserted, not the catalog size — that changes whenever a + // permission is added or dropped, and it isn't what this test is about. + const granted = (n: number) => new RegExp(`\\b${n}/\\d+ permissions granted`); + + await edit.check(); + await expect(view).toBeChecked(); + await expect(page.getByText(granted(2))).toBeVisible(); + + await del.check(); + await expect(page.getByText(granted(3))).toBeVisible(); + + // Removing View takes the whole area with it. + await view.uncheck(); + await expect(edit).not.toBeChecked(); + await expect(del).not.toBeChecked(); + await expect(page.getByText(granted(0))).toBeVisible(); +}); + +test("the access preview shows what the role would see", async ({ page }) => { + await page.goto("team/roles/new"); + + await expect(page.getByText("No pages yet — grant a View permission.")).toBeVisible(); + + await page.getByRole("checkbox", { name: "Partners: View", exact: true }).check(); + const preview = page.locator("section, div").filter({ hasText: "What members with this role see" }).last(); + await expect(preview.getByText("Partners")).toBeVisible(); + await expect(preview.getByText("Exchanges")).toHaveCount(0); + + await page.getByRole("checkbox", { name: "Exchanges: View", exact: true }).check(); + await expect(preview.getByText("Exchanges")).toBeVisible(); +}); + +test("built-in roles are read-only", async ({ page }) => { + await page.goto("team/roles"); + await page.getByRole("link", { name: /Administrator/ }).click(); + + await expect(page.getByText("This role is built in")).toBeVisible(); + await expect(page.getByRole("checkbox", { name: "Partners: View", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Delete role" })).toHaveCount(0); + // Name and description aren't even rendered for a built-in. + await expect(page.locator("#role-name")).toHaveCount(0); +}); + +test("a role in use can't be deleted", async ({ page }) => { + const roleName = `PW InUse ${Date.now()}`; + await createRole(page, { name: roleName, permissions: [{ area: "Exchanges", action: "View" }] }); + const email = await addMember(page, { name: "Role Holder", roles: [roleName] }); + + await page.goto("team/roles"); + await expect(page.getByRole("link", { name: new RegExp(roleName) })).toContainText("1 member"); + + await page.getByRole("link", { name: new RegExp(roleName) }).click(); + await page.getByRole("button", { name: "Delete role" }).click(); + await page.getByRole("button", { name: "Delete role" }).last().click(); + await expect(page.getByText(/is still assigned to 1 member/i)).toBeVisible(); + + // Free the role up, and the delete goes through. + await removeMember(page, email); + await deleteRole(page, roleName); + await expect(page.getByText(roleName)).toHaveCount(0); +}); + +test("two roles can't share a name", async ({ page }) => { + await page.goto("team/roles/new"); + await page.fill("#role-name", "Administrator"); + await page.fill("#role-desc", "Should be refused."); + await page.getByRole("checkbox", { name: "Exchanges: View", exact: true }).check(); + await page.getByRole("button", { name: "Create role" }).click(); + + await expect(page.getByText(/already exists/i)).toBeVisible(); + await expect(page).toHaveURL(/\/team\/roles\/new$/); +}); + +test("duplicate a role", async ({ page }) => { + const original = `PW Source ${Date.now()}`; + await createRole(page, { + name: original, + permissions: [ + { area: "Partners", action: "View" }, + { area: "Partners", action: "Edit" }, + ], + }); + + await page.getByRole("link", { name: new RegExp(original) }).click(); + await page.getByRole("button", { name: "Duplicate" }).click(); + + await expect(page.locator("#role-name")).toHaveValue(`Copy of ${original}`); + await expect(page.getByRole("checkbox", { name: "Partners: Edit", exact: true })).toBeChecked(); + + const copy = `PW Copy ${Date.now()}`; + await page.fill("#role-name", copy); + await page.getByRole("button", { name: "Create role" }).click(); + await page.waitForURL(/\/team\/roles$/); + + await expect(page.getByRole("link", { name: new RegExp(copy) })).toBeVisible(); + + await deleteRole(page, copy); + await deleteRole(page, original); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/view-guards.spec.ts b/SW.Bitween.Web/ClientApp/e2e/view-guards.spec.ts new file mode 100644 index 00000000..be9133a7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/view-guards.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test"; +import { + FIRST_PASSWORD, + addMember, + createRole, + deleteRole, + removeMember, + signIn, + signInAsAdmin, + signOut, +} from "./helpers"; + +/** + * Reads are permission-guarded too, which is easy to get wrong in the other direction: a page can + * legitimately need data from an area the viewer has no business browsing. These cover both sides — + * what a narrow role can't read, and the pages it can still open in full. + */ + +async function apiStatus(page: import("@playwright/test").Page, path: string): Promise { + const token = await page.evaluate(() => localStorage.getItem("access_token")); + const res = await page.request.fetch(`https://localhost:7155/api${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.status(); +} + +test("a role with one view permission can't read any other area's list", async ({ page }) => { + const roleName = `PW Docs Reader ${Date.now()}`; + await signInAsAdmin(page); + await createRole(page, { + name: roleName, + permissions: [{ area: "Information types", action: "View" }], + }); + const email = await addMember(page, { name: "Docs Reader", roles: [roleName] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + // The one area they hold is readable. + expect(await apiStatus(page, "/documents")).toBe(200); + + // Every other list is refused, not merely hidden in the nav. + for (const path of [ + "/partners", + "/xchanges", + "/subscriptions", + "/notifiers", + "/apigateways", + "/busgateways", + "/retrypolicies", + "/globaladaptervaluessets", + "/workgroups", + "/delayedretries", + "/ops/summary", + ]) + expect(await apiStatus(page, path), `${path} should be refused`).toBe(401); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); + await deleteRole(page, roleName); +}); + +test("lookup mode stays readable, because pickers across the app depend on it", async ({ page }) => { + const roleName = `PW Lookup Only ${Date.now()}`; + await signInAsAdmin(page); + await createRole(page, { + name: roleName, + permissions: [{ area: "Information types", action: "View" }], + }); + const email = await addMember(page, { name: "Lookup User", roles: [roleName] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + // id/name pairs only — what a picker needs, and not the data the guard protects. + for (const path of [ + "/partners?lookup=true", + "/subscriptions?lookup=true", + "/retrypolicies?lookup=true", + "/accounts?lookup=true", + ]) + expect([200, 206], `${path} should be allowed in lookup mode`).toContain( + await apiStatus(page, path), + ); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); + await deleteRole(page, roleName); +}); + +test("a page still loads when the area behind its Used by count is refused", async ({ page }) => { + const roleName = `PW No Integrations ${Date.now()}`; + await signInAsAdmin(page); + await createRole(page, { + name: roleName, + permissions: [{ area: "Information types", action: "View" }], + }); + const email = await addMember(page, { name: "No Integrations", roles: [roleName] }); + + await signOut(page); + await signIn(page, email, FIRST_PASSWORD); + + // The information types list counts how many integrations use each type, which needs the + // integrations list this role can't read. The count is what's expendable, not the page. + await page.goto("information-types"); + await expect(page.getByText("You don't have access to this page")).toHaveCount(0); + await expect(page.getByRole("table")).toBeVisible(); + await expect(page.getByText(/failed|error/i)).toHaveCount(0); + + await signOut(page); + await signInAsAdmin(page); + await removeMember(page, email); + await deleteRole(page, roleName); +}); diff --git a/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts b/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts new file mode 100644 index 00000000..793d714e --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from "@playwright/test"; + +const ADMIN_EMAIL = "admin@Bitween.systems"; +const ADMIN_PASSWORD = "Mtm@dmin!2"; + +test.beforeEach(async ({ page }) => { + await page.goto("login"); + await page.fill("#login-email", ADMIN_EMAIL); + await page.fill("#login-password", ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); +}); + +test("work group create, edit queue settings, list, delete", async ({ page }) => { + const name = `Playwright Workgroup ${Date.now()}`; + + await page.goto("work-groups/new"); + await page.fill("#nwg-name", name); + await page.getByRole("button", { name: "Create work group" }).click(); + + await expect(page).toHaveURL(/\/work-groups\/\d+$/); + await expect(page.getByRole("heading", { name })).toBeVisible(); + // Defaults from the new-page form. + await expect(page.locator("#wg-prefetch")).toHaveValue("10"); + await expect(page.locator("#wg-priority")).toHaveValue("5"); + + await page.fill("#wg-prefetch", "25"); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("button", { name: "Save changes" })).toHaveCount(0); + + // Reload forces a fresh GET — WorkGroups/Search.cs now reads the DB + // directly (not IInfolinkCache), so this reflects the update immediately. + await page.reload(); + await expect(page.locator("#wg-prefetch")).toHaveValue("25"); + + await page.goto("work-groups"); + const row = page.getByRole("row", { name: new RegExp(name) }); + await expect(row).toBeVisible(); + // A brand-new group has no consumers and nothing assigned to it, so both of + // those render as an em dash. + await expect(row.getByText("—")).toHaveCount(2); + + await row.getByRole("button", { name: `Open ${name}` }).click(); + await expect(page).toHaveURL(/\/work-groups\/\d+$/); + await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete work group" }).click(); + await expect(page).toHaveURL(/\/work-groups$/); + await expect(page.getByText(name)).toHaveCount(0); +}); diff --git a/SW.Bitween.Web/ClientApp/index.html b/SW.Bitween.Web/ClientApp/index.html new file mode 100644 index 00000000..fb83c6a2 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/index.html @@ -0,0 +1,13 @@ + + + + + + + Bitween + + +
+ + + diff --git a/SW.Bitween.Web/ClientApp/package.json b/SW.Bitween.Web/ClientApp/package.json new file mode 100644 index 00000000..9d82fbce --- /dev/null +++ b/SW.Bitween.Web/ClientApp/package.json @@ -0,0 +1,40 @@ +{ + "name": "bitween-ui-next", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "@azure/msal-browser": "^4.0.1", + "@fontsource-variable/instrument-sans": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@headlessui/react": "^2.2.10", + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", + "immer": "^11.1.8", + "lucide-react": "^1.24.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.2.0", + "tailwindcss": "^4.3.2" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.7" + } +} diff --git a/SW.Bitween.Web/ClientApp/playwright.config.ts b/SW.Bitween.Web/ClientApp/playwright.config.ts new file mode 100644 index 00000000..81c5ca1c --- /dev/null +++ b/SW.Bitween.Web/ClientApp/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "@playwright/test"; + +// Points at the locally running backend (SW.Bitween.Web.Local), which serves +// the built SPA at the site root — there is no separate dev server to boot. +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + reporter: "list", + // Tests run against a real database, so a failed run leaves data behind. This clears it and + // repairs the admin's roles before anything else, so one failure can't cascade into the next run. + globalSetup: "./e2e/global-setup.ts", + use: { + baseURL: "https://localhost:7155/", + ignoreHTTPSErrors: true, + }, +}); diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg new file mode 100644 index 00000000..7fc64cc9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg new file mode 100644 index 00000000..1546a5c1 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.png b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.png new file mode 100644 index 00000000..1f22ce3f Binary files /dev/null and b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.png differ diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg new file mode 100644 index 00000000..b2e1faf3 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/favicon.svg b/SW.Bitween.Web/ClientApp/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/icons.svg b/SW.Bitween.Web/ClientApp/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts new file mode 100644 index 00000000..44c5a2ca --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -0,0 +1,307 @@ +import type { + AdapterInfo, + AdapterKind, + ApiGateway, + ApiGatewayDetail, + ApiGatewayRow, + BusGateway, + BusGatewayDetail, + BusGatewayRow, + DashboardData, + ExchangeQuery, + ExchangeRow, + GlobalValuesSetDetail, + GlobalValuesSetRow, + InformationType, + InformationTypeDetail, + InformationTypeFormat, + InformationTypeRow, + Integration, + IntegrationDetail, + IntegrationInfo, + IntegrationRow, + IntegrationType, + MatchGroup, + Notifier, + NotifierDetail, + Paged, + Partner, + PartnerDetail, + PartnerRow, + PermissionArea, + PermissionKey, + QueueHealthSnapshot, + RetryGroup, + RetryPolicy, + RetryPolicyDetail, + RetryPolicyListRow, + RetryResultType, + RetryTestAttempt, + Role, + Schedule, + ScheduledRetryQuery, + ScheduledRetryRow, + Session, + SettingRow, + User, + WorkGroup, + WorkGroupDetail, + WorkGroupRow, +} from "./types"; + +/** + * The single data-access contract the UI is written against, implemented by + * ./http/httpClient. Components depend on this interface rather than on fetch, + * so an endpoint's shape can change in one place. + */ +export interface ApiClient { + // — session — + getSession(): Promise; + login(email: string, password: string): Promise; + loginWithMicrosoft(): Promise; + 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; + + // — members — + 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. + */ + createUser(input: { + displayName: string; + email: string; + password: string; + roleIds: string[]; + }): Promise; + /** Stands in for a self-service reset, which would need mail Bitween doesn't have. */ + setUserPassword(id: string, password: string): Promise; + updateUserRoles(id: string, roleIds: string[]): Promise; + setUserDisabled(id: string, disabled: boolean): Promise; + deleteUser(id: string): Promise; + + // — roles — + /** The catalog the backend enforces, so the role matrix can never offer a grant it ignores. */ + getPermissionCatalog(): Promise; + listRoles(): Promise; + getRole(id: string): Promise; + createRole(input: { name: string; description: string; permissions: PermissionKey[] }): Promise; + updateRole( + id: string, + input: { name: string; description: string; permissions: PermissionKey[] }, + ): Promise; + deleteRole(id: string): Promise; + + // — partners — + listPartners(): Promise; + getPartner(id: number): Promise; + /** Light fetch used by the mapper editor's test-partner selector. */ + getPartnerAdapterProperties(id: number): Promise>; + createPartner(input: { name: string }): Promise; + updatePartner( + id: number, + changes: { name?: string; adapterProperties?: Record }, + ): Promise; + deletePartner(id: number): Promise; + /** Returns the full key exactly once; afterwards only a prefix is ever shown. */ + addPartnerCredential(id: number, name: string): Promise<{ key: string }>; + revokePartnerCredential(id: number, name: string): Promise; + + // — information types — + listInformationTypes(): Promise; + getInformationType(id: number): Promise; + createInformationType(input: { + name: string; + code: string; + format: InformationTypeFormat; + busEnabled?: boolean; + busMessageTypeName?: string; + }): Promise; + updateInformationType( + id: number, + changes: Omit, + ): Promise; + deleteInformationType(id: number): Promise; + + // — global values — + listValueSets(): Promise; + getValueSet(id: string): Promise; + createValueSet(input: { + id: string; + name: string; + values: Record; + }): Promise; + updateValueSet( + id: string, + changes: { name: string; values: Record }, + ): Promise; + deleteValueSet(id: string): Promise; + + // — integrations (light summaries; cache aggressively) — + listIntegrations(): Promise; + + // — integrations — + listIntegrationRows(): Promise; + getIntegration(id: number): Promise; + /** Only Receiving / GatewayApiCall / BusGateway — always via the entry-point wizards. */ + createIntegration(input: { + type: IntegrationType; + name: string; + informationTypeId: number; + receiverId?: string | null; + receiverProperties?: Record; + validatorId?: string | null; + validatorProperties?: Record; + mapperId?: string | null; + mapperProperties?: Record; + handlerId?: string | null; + handlerProperties?: Record; + schedules?: Schedule[]; + retryPolicyId?: number | null; + enabled?: boolean; + }): Promise; + updateIntegration( + id: number, + changes: Partial< + Pick< + Integration, + | "name" + | "enabled" + | "workGroupId" + | "retryPolicyId" + | "receiverId" + | "receiverProperties" + | "validatorId" + | "validatorProperties" + | "mapperId" + | "mapperProperties" + | "handlerId" + | "handlerProperties" + | "matchExpression" + | "schedules" + | "responseIntegrationId" + | "responseMessageTypeName" + > + >, + ): Promise; + deleteIntegration(id: number): Promise; + /** Toggles paused: paused integrations accept work but hold it. */ + pauseIntegration(id: number): Promise; + receiveNow(id: number): Promise; + listAdapters(kind: AdapterKind): Promise; + + // — work groups — + listWorkGroups(): Promise; + getWorkGroup(id: number): Promise; + createWorkGroup(input: { + name: string; + busMessageName: string; + prefetch: number; + priority: number; + }): Promise; + updateWorkGroup( + id: number, + changes: { name: string; busMessageName: string; prefetch: number; priority: number }, + ): Promise; + deleteWorkGroup(id: number): Promise; + + // — API gateways — + listApiGateways(): Promise; + getApiGateway(id: number): Promise; + createApiGateway(input: { name: string; urlName: string }): Promise; + updateApiGateway(id: number, changes: { name: string; urlName: string }): Promise; + deleteApiGateway(id: number): Promise; + attachGatewayPartner(id: number, input: { partnerId: number; integrationId: number }): Promise; + updateGatewayAttachment(id: number, input: { partnerId: number; integrationId: number }): Promise; + removeGatewayAttachment(id: number, partnerId: number): Promise; + + // — bus gateways — + listBusGateways(): Promise; + getBusGateway(id: number): Promise; + createBusGateway(input: { name: string; informationTypeId: number }): Promise; + updateBusGateway(id: number, changes: { name: string }): Promise; + deleteBusGateway(id: number): Promise; + addBusRoute( + id: number, + input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, + ): Promise; + updateBusRoute( + id: number, + routeId: number, + input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, + ): Promise; + removeBusRoute(id: number, routeId: number): Promise; + + // — retry policies — + listRetryPolicies(): Promise; + getRetryPolicy(id: number): Promise; + createRetryPolicy(input: { name: string }): Promise; + updateRetryPolicy(id: number, changes: { name: string; groups: RetryGroup[] }): Promise; + deleteRetryPolicy(id: number): Promise; + /** Dry-runs draft groups against a simulated failure over N attempts. */ + testRetryPolicy(input: { + groups: RetryGroup[]; + resultType: RetryResultType; + content: string; + attempts: number; + }): Promise; + + // — settings — + listSettings(): Promise; + /** `value: null` resets the setting back to its default. */ + updateSetting(key: string, value: string | null): Promise; + + // — notifiers — + // No backend delete/test-send endpoint exists yet (BACKEND_WIRING_PLAN.md G8) — hidden in the UI. + // Channel choices come from listAdapters("handler") — same catalog as any other handler slot. + listNotifiers(): Promise; + getNotifier(id: number): Promise; + createNotifier(input: { name: string }): Promise; + updateNotifier(id: number, changes: Omit): Promise; + + // — exchanges — + searchExchanges(query: ExchangeQuery): Promise>; + /** Fetches a stage document's raw text content by its storage key (`ExchangeFileRef.key`). */ + getExchangeDocument(key: string): Promise; + /** + * Re-runs an exchange from its input file. `reset` re-resolves adapter + * properties from the integration's current configuration instead of the + * values captured when the exchange first ran. Fails with + * AUTO_RETRY_SCHEDULED when an auto-retry is already pending. + */ + retryExchange(id: string, opts: { reset: boolean }): Promise<{ id: string }>; + /** Retries many; exchanges with a pending auto-retry are skipped, not failed. */ + bulkRetryExchanges(ids: string[], opts: { reset: boolean }): Promise<{ retried: number; skipped: number }>; + /** Manually injects a payload, addressed at an integration or an information type. */ + createExchange(input: { + target: "integration" | "informationType"; + integrationId?: number; + informationTypeId?: number; + data: string; + }): Promise<{ id: string }>; + + // — scheduled retries — + searchScheduledRetries(query: ScheduledRetryQuery): Promise>; + /** Executes a pending auto-retry immediately instead of waiting for its slot. */ + runScheduledRetryNow(id: string): Promise; + + // — queue health — + getQueueHealth(): Promise; + + // — dashboard — + getDashboard(): Promise; + + // — mappers — + /** Executes a Scriban template against sample input, injecting the partner's adapter properties and global value sets exactly as the runtime mapper does. */ + previewMapping(input: { + scribanTemplate: string; + inputJson: string; + partnerId?: number | null; + }): Promise<{ outputJson: string | null; error: string | null }>; +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts b/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts new file mode 100644 index 00000000..c6938a49 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts @@ -0,0 +1,53 @@ +import type { ApiClient } from "../client"; +import type { AdapterInfo, AdapterKind, AdapterProp } from "../types"; +import { get } from "./request"; + +interface RawVersionedAdapter { + key: string; + versions: string[] | null; +} +interface RawStartupValue { + optional: boolean; + default: string | null; + private: boolean; + description: string | null; +} + +// The backend's Prefix param takes the plural, lowercase form. +const KIND_PREFIX: Record = { + receiver: "receivers", + handler: "handlers", + mapper: "mappers", + validator: "validators", +}; + +async function fetchProps(id: string): Promise { + const values = await get>(`/adapters/${encodeURIComponent(id)}/GetStartupValues`); + return Object.entries(values ?? {}).map(([key, v]) => ({ + key, + optional: v.optional, + default: v.default ?? undefined, + secret: v.private, + description: v.description ?? undefined, + })); +} + +export const adapterMethods = { + async listAdapters(kind: AdapterKind): Promise { + const rows = await get(`/adapters/Versioned?prefix=${KIND_PREFIX[kind]}`); + return Promise.all( + (rows ?? []).map(async (r) => ({ + id: r.key, + kind, + // No backend source for a friendly display name — fall back to the raw id. + label: r.key, + native: r.key.toLowerCase().startsWith("native"), + versions: r.versions ?? [], + // Legacy (non-native) adapters can fail to report startup values (e.g. their + // serverless runtime isn't available locally) — don't let that blank out the + // whole catalog, including the native adapters that did resolve fine. + props: await fetchProps(r.key).catch(() => []), + })), + ); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts b/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts new file mode 100644 index 00000000..65216886 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts @@ -0,0 +1,37 @@ +import { API_BASE } from "./request"; + +/** The `[Unprotect]` GET /settings/config payload the login page needs pre-auth. */ +export interface AppConfig { + msalClientId?: string | null; + msalTenantId?: string | null; + msalRedirectUri?: string | null; + /** When true the backend rejects email/password sign-in outright, so don't offer the form. */ + disableEmailPasswordLogin?: boolean; + isRabbitMqManagementConfigured?: 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". */ + themeDefaults?: Record; +} + +let cached: Promise | null = null; + +/** + * Bootstrap config served before authentication (MSAL parameters, feature + * flags, branding). Fetched once and memoised. Never throws to callers — an + * unreachable backend just yields an empty config, so the login page degrades + * gracefully. + */ +export function getAppConfig(): Promise { + if (!cached) { + cached = fetch(`${API_BASE}/settings/config`, { credentials: "include" }) + .then((res) => (res.ok ? (res.json() as Promise) : {})) + .catch(() => ({})); + } + return cached; +} + +/** Drops the memoised copy, so the next read picks up a brand setting that was just saved. */ +export function resetAppConfig(): void { + cached = null; +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts new file mode 100644 index 00000000..7a7279c1 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -0,0 +1,132 @@ +import type { ApiClient } from "../client"; +import type { DashboardData, ExchangeStatus } from "../types"; +import { integrationMethods } from "./integrations"; +import { get } from "./request"; + +// ——— backend shapes (camelCase over the wire) ——— +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawXchangeForDashboard { + id: string; + subscriptionId: number | null; + documentName: string; + status: boolean | null; + responseBad: boolean | null; + exception: string | null; + startedOn: string; +} +interface RawAlert { + severity: "Info" | "Warning" | "Critical"; +} + +const isBad = (raw: Pick) => + raw.status === false || (raw.status === true && raw.responseBad === true); +const isProcessing = (raw: Pick) => raw.status === null; +const toStatus = (raw: Pick): ExchangeStatus => + raw.status === null ? "processing" : !raw.status ? "failed" : raw.responseBad ? "badResponse" : "success"; + +export const dashboardMethods = { + async getDashboard(): Promise { + const dayMs = 86_400_000; + const startOfTodayUtc = new Date(new Date().setUTCHours(0, 0, 0, 0)).getTime(); + // 14 calendar days including today. + const windowStart = startOfTodayUtc - 13 * dayMs; + + // One bulk fetch covers every stat that's derived from exchange rows + // (today/yesterday/successRate7d/trafficByDay/busiest/latestFailures) — + // far fewer round trips than counting each bucket with its own filtered + // request, and exact rather than approximated. Bounded to a generous page + // size for what's a modest-scale ops tool; a very high-volume deployment + // would need real pagination here. + const [xchangeRes, delayedRes, alertsRaw, integrationRows] = await Promise.all([ + get>( + `/xchanges?filter=${encodeURIComponent(`StartedOn:6:${new Date(windowStart).toISOString()}`)}&size=1000&sort=StartedOn:1`, + ), + get>("/delayedretries?size=1"), + get("/ops/alerts"), + integrationMethods.listIntegrationRows(), + ]); + + const rows = xchangeRes.result; + const startedAt = (r: RawXchangeForDashboard) => Date.parse(r.startedOn); + const integrationNameById = new Map(integrationRows.map((i) => [i.id, i.name])); + + const todayRows = rows.filter((r) => startedAt(r) >= startOfTodayUtc); + const yesterdayRows = rows.filter((r) => { + const t = startedAt(r); + return t >= startOfTodayUtc - dayMs && t < startOfTodayUtc; + }); + // The last 7 calendar days including today — the same boundary as the + // final 7 entries of trafficByDay below. + const sevenDaysAgo = startOfTodayUtc - 6 * dayMs; + const week = rows.filter((r) => startedAt(r) >= sevenDaysAgo); + const finished7d = week.filter((r) => !isProcessing(r)); + const successRate7d = + finished7d.length === 0 + ? 100 + : Math.round((finished7d.filter((r) => !isBad(r)).length / finished7d.length) * 100); + + const trafficByDay = Array.from({ length: 14 }, (_, i) => { + const dayStart = windowStart + i * dayMs; + const dayRows = rows.filter((r) => { + const t = startedAt(r); + return t >= dayStart && t < dayStart + dayMs; + }); + return { + date: new Date(dayStart).toISOString(), + success: dayRows.filter((r) => !isBad(r)).length, + failed: dayRows.filter(isBad).length, + }; + }); + + const byIntegration = new Map(); + for (const r of week) { + if (r.subscriptionId === null) continue; + const entry = byIntegration.get(r.subscriptionId) ?? { count: 0, failed: 0 }; + entry.count++; + if (isBad(r)) entry.failed++; + byIntegration.set(r.subscriptionId, entry); + } + const busiest = [...byIntegration.entries()] + .map(([id, v]) => ({ id, name: integrationNameById.get(id) ?? `#${id}`, ...v })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const latestFailures = rows + .filter(isBad) + .sort((a, b) => b.startedOn.localeCompare(a.startedOn)) + .slice(0, 6) + .map((r) => ({ + id: r.id, + status: toStatus(r), + integrationId: r.subscriptionId, + integrationName: r.subscriptionId !== null ? (integrationNameById.get(r.subscriptionId) ?? null) : null, + informationTypeCode: r.documentName, + on: r.startedOn, + exception: r.exception, + })); + + return { + today: { + total: todayRows.length, + failed: todayRows.filter(isBad).length, + processing: todayRows.filter(isProcessing).length, + }, + yesterdayTotal: yesterdayRows.length, + successRate7d, + pendingRetries: delayedRes.totalCount, + queueAlerts: alertsRaw.filter((a) => a.severity !== "Info").length, + trafficByDay, + busiest, + latestFailures, + attention: { + failingIntegrations: integrationRows + .filter((i) => i.consecutiveFailures > 0) + .map((i) => ({ id: i.id, name: i.name, consecutiveFailures: i.consecutiveFailures })), + pausedIntegrations: integrationRows.filter((i) => i.paused).map((i) => ({ id: i.id, name: i.name })), + }, + }; + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts new file mode 100644 index 00000000..ad0095f9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts @@ -0,0 +1,176 @@ +import type { ApiClient } from "../client"; +import type { + InformationType, + InformationTypeDetail, + InformationTypeFormat, + InformationTypeRow, + IntegrationType, + TrailEntry, +} from "../types"; +import { exchangeMethods } from "./exchanges"; +import { gatewayMethods } from "./gateways"; +import { get, getEnrichment, post, request } from "./request"; + +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawKeyAndValue { + key: string; + value: string; +} +interface RawDocument { + id: number; + code: string | null; + name: string; + documentFormat: InformationTypeFormat; + busEnabled: boolean; + busMessageTypeName: string | null; + duplicateInterval: number; + disregardsUnfilteredMessages: boolean; + promotedProperties: RawKeyAndValue[] | null; +} +interface RawSubscriptionRef { + id: number; + name: string; + type: number | string; +} +interface RawTrailEntry { + createdOn: string; + code: "Created" | "Updated"; + createdBy: string; +} + +const SUB_TYPE_BY_NUM: Record = { + 1: "Internal", + 2: "ApiCall", + 4: "Receiving", + 8: "Aggregation", + 16: "GatewayApiCall", + 32: "BusGateway", +}; +const INTEGRATION_TYPES: IntegrationType[] = [ + "Receiving", + "GatewayApiCall", + "BusGateway", + "Internal", + "ApiCall", + "Aggregation", +]; +/** Enums may arrive as the numeric value or the name in any case. */ +const toIntegrationType = (t: number | string): IntegrationType => { + if (typeof t === "number") return SUB_TYPE_BY_NUM[t] ?? "Internal"; + return INTEGRATION_TYPES.find((k) => k.toLowerCase() === t.toLowerCase()) ?? "Internal"; +}; + +async function fetchSubscriptionsByDocument(documentId: number): Promise { + const res = await get>( + `/subscriptions?filter=${encodeURIComponent(`DocumentId:1:${documentId}`)}`, + ); + return res.result ?? []; +} + +async function fetchTrail(documentId: number): Promise { + const [trail, accountNames] = await Promise.all([ + get>(`/documents/trail?documentId=${documentId}&limit=8`), + get>("/accounts?lookup=true"), + ]); + return (trail.result ?? []).map((t) => ({ + on: t.createdOn, + action: t.code, + by: accountNames[t.createdBy] ?? "System", + byUserId: accountNames[t.createdBy] ? t.createdBy : undefined, + })); +} + +const toInformationType = (d: RawDocument): InformationType => ({ + id: d.id, + code: d.code ?? undefined, + name: d.name, + format: d.documentFormat, + busEnabled: d.busEnabled, + busMessageTypeName: d.busMessageTypeName ?? undefined, + duplicateIntervalMinutes: d.duplicateInterval, + disregardsUnfilteredMessages: d.disregardsUnfilteredMessages, + promotedProperties: (d.promotedProperties ?? []).map((p) => ({ key: p.key, path: p.value })), + createdOn: "", +}); + +async function fetchDetail(id: number): Promise { + const [d, subs, busGateways, recentExchanges, trail] = await Promise.all([ + get(`/documents/${id}`), + fetchSubscriptionsByDocument(id), + gatewayMethods.listBusGateways(), + exchangeMethods.searchExchanges({ informationTypeId: id, offset: 0, limit: 8 }), + fetchTrail(id), + ]); + return { + ...toInformationType(d), + integrationSetups: subs.map((s) => ({ id: s.id, name: s.name, type: toIntegrationType(s.type) })), + busGateways: busGateways + .filter((g) => g.informationTypeId === id) + .map((g) => ({ gatewayId: g.id, gatewayName: g.name })), + trail, + recentExchanges: recentExchanges.result.map((x) => ({ + id: x.id, + partnerName: x.partnerName ?? undefined, + informationTypeCode: x.informationTypeCode, + status: x.status, + on: x.startedOn, + })), + }; +} + +export const documentMethods = { + async listInformationTypes(): Promise { + const [res, subs] = await Promise.all([ + get>("/documents"), + getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), + ]); + const countByDocument = new Map(); + for (const s of subs.result ?? []) + countByDocument.set(s.documentId, (countByDocument.get(s.documentId) ?? 0) + 1); + return (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })); + }, + + getInformationType: fetchDetail, + + async createInformationType(input: { + name: string; + code?: string; + format: InformationTypeFormat; + busEnabled?: boolean; + busMessageTypeName?: string; + }): Promise { + const id = await post("/documents", { + code: input.code?.trim() || undefined, + name: input.name, + documentFormat: input.format, + busEnabled: input.busEnabled ?? false, + busMessageTypeName: input.busEnabled ? input.busMessageTypeName : undefined, + }); + return fetchDetail(id); + }, + + async updateInformationType( + id: number, + changes: Omit, + ): Promise { + await post(`/documents/${id}`, { + id, + code: changes.code?.trim() || undefined, + name: changes.name, + documentFormat: changes.format, + busEnabled: changes.busEnabled, + busMessageTypeName: changes.busEnabled ? changes.busMessageTypeName : undefined, + duplicateInterval: changes.duplicateIntervalMinutes, + disregardsUnfilteredMessages: changes.disregardsUnfilteredMessages, + promotedProperties: changes.promotedProperties.map((p) => ({ key: p.key, value: p.path })), + }); + return fetchDetail(id); + }, + + async deleteInformationType(id: number): Promise { + await request(`/documents/${id}`, { method: "DELETE" }); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts new file mode 100644 index 00000000..2b0d3c84 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -0,0 +1,233 @@ +import type { ApiClient } from "../client"; +import type { + ExchangeQuery, + ExchangeRow, + ExchangeStatus, + Paged, + ScheduledRetryQuery, + ScheduledRetryRow, +} from "../types"; +import { partnerMethods } from "./partners"; +import { get, post } from "./request"; + +// ——— backend shapes (camelCase over the wire) ——— +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawXchangeRow { + id: string; + subscriptionId: number | null; + subscriptionName: string | null; + documentId: number; + documentName: string; + mapperId: string | null; + status: boolean | null; + exception: string | null; + finishedOn: string | null; + startedOn: string; + inputFileName: string | null; + outputFileName: string | null; + responseFileName: string | null; + inputKey: string | null; + outputKey: string | null; + responseKey: string | null; + promotedProperties: Record | null; + retryFor: string | null; + aggregationXchangeId: string | null; + responseBad: boolean | null; + correlationId: string | null; + partnerId: number | null; + scheduledRetryOn: string | null; +} +interface RawDelayedRetryRow { + id: string; + on: string; + subscriptionId: number | null; + subscriptionName: string | null; + documentId: number; + documentName: string; + exception: string | null; + startedOn: string; + retryPolicyId: number | null; + retryPolicyName: string | null; +} + +/** + * The backend has no real ExchangeStatus enum — it's derived from two + * nullable booleans. `status == null` while still running; once set, a bad + * *response* (the handler delivered but the receiver answered with an error) + * is distinct from an outright failure. + */ +const deriveStatus = (raw: Pick): ExchangeStatus => + raw.status === null ? "processing" : !raw.status ? "failed" : raw.responseBad ? "badResponse" : "success"; + +const STATUS_FILTER: Record = { + processing: 0, + success: 1, + badResponse: 2, + failed: 3, +}; + +const toExchangeRow = (raw: RawXchangeRow, partnerNameById: Map): ExchangeRow => ({ + id: raw.id, + status: deriveStatus(raw), + integrationId: raw.subscriptionId, + integrationName: raw.subscriptionName, + informationTypeId: raw.documentId, + informationTypeCode: raw.documentName, + partnerId: raw.partnerId, + partnerName: raw.partnerId !== null ? (partnerNameById.get(raw.partnerId) ?? null) : null, + startedOn: raw.startedOn, + finishedOn: raw.finishedOn, + correlationId: raw.correlationId, + retryFor: raw.retryFor, + aggregationXchangeId: raw.aggregationXchangeId, + scheduledRetryOn: raw.scheduledRetryOn, + exception: raw.exception, + promotedProperties: raw.promotedProperties, + mapperSkipped: raw.mapperId === null, + // Search's projection never populates file sizes/hashes (always 0 at the + // source) — show the name, which is real, with a size of 0 rather than + // fabricating one. Existence is keyed off `*Key` (backend only emits one once + // the file actually has bytes), not the file name, since gateway/manually + // created exchanges have no name yet content still exists. + files: { + input: raw.inputKey ? { name: raw.inputFileName ?? "input", size: 0, key: raw.inputKey } : null, + mapped: raw.outputKey ? { name: raw.outputFileName ?? "mapped", size: 0, key: raw.outputKey } : null, + handled: raw.responseKey ? { name: raw.responseFileName ?? "handled", size: 0, key: raw.responseKey } : null, + }, +}); + +const toScheduledRetryRow = (raw: RawDelayedRetryRow): ScheduledRetryRow => ({ + id: raw.id, + on: raw.on, + integrationId: raw.subscriptionId, + integrationName: raw.subscriptionName, + informationTypeId: raw.documentId, + informationTypeCode: raw.documentName, + exception: raw.exception, + startedOn: raw.startedOn, + retryPolicyId: raw.retryPolicyId, + retryPolicyName: raw.retryPolicyName, +}); + +/** + * The backend's date-`Range` filter (rule 21) is unconditionally broken: its + * shared parser (`SearchyFilter.cs`, SW-PrimitiveTypes) does `DateTime.Parse` + * with no `DateTimeStyles`, which can only produce `Local` or `Unspecified` + * `Kind` — Npgsql then refuses to bind it to a `timestamptz` column and the + * whole search 500s, regardless of what the client sends. Two scalar + * comparisons (`GreaterThanOrEquals`/`LessThanOrEquals`, rules 6/8) go + * through a different code path and work correctly — use those instead. + */ +function buildExchangeQuery(query: ExchangeQuery): string { + const params = new URLSearchParams(); + if (query.status) params.append("filter", `StatusFilter:1:${STATUS_FILTER[query.status]}`); + if (query.integrationId !== undefined) params.append("filter", `SubscriptionId:1:${query.integrationId}`); + if (query.partnerId !== undefined) params.append("filter", `PartnerId:1:${query.partnerId}`); + if (query.informationTypeId !== undefined) params.append("filter", `DocumentId:1:${query.informationTypeId}`); + if (query.ids?.trim()) { + const ids = query.ids.split(/[\s,|]+/).filter(Boolean); + params.append("filter", `Id:4:text|${ids.join("|")}`); + } + if (query.correlationId?.trim()) params.append("filter", `CorrelationId:1:${query.correlationId.trim()}`); + if (query.property?.trim()) params.append("filter", `PromotedPropertiesRaw:4:${query.property.trim()}`); + if (query.from) params.append("filter", `StartedOn:6:${query.from}`); + if (query.to) params.append("filter", `StartedOn:8:${query.to}`); + params.set("page", String(Math.floor(query.offset / query.limit))); + params.set("size", String(query.limit)); + return params.toString(); +} + +function buildScheduledRetryQuery(query: ScheduledRetryQuery): string { + const params = new URLSearchParams(); + if (query.integrationId !== undefined) params.append("filter", `SubscriptionId:1:${query.integrationId}`); + if (query.informationTypeId !== undefined) params.append("filter", `DocumentId:1:${query.informationTypeId}`); + if (query.exception?.trim()) params.append("filter", `Exception:4:${query.exception.trim()}`); + if (query.from) params.append("filter", `On:6:${query.from}`); + if (query.to) params.append("filter", `On:8:${query.to}`); + params.set("page", String(Math.floor(query.offset / query.limit))); + params.set("size", String(query.limit)); + return params.toString(); +} + +async function partnerNameMap(): Promise> { + const partners = await partnerMethods.listPartners(); + return new Map(partners.map((p) => [p.id, p.name])); +} + +export const exchangeMethods = { + async getExchangeDocument(key: string): Promise { + const res = await get<{ data: string; key: string }>(`/bitweendocs?documentKey=${encodeURIComponent(key)}`); + return res.data; + }, + + async searchExchanges(query: ExchangeQuery): Promise> { + const [res, partnerNameById] = await Promise.all([ + get>(`/xchanges?${buildExchangeQuery(query)}`), + partnerNameMap(), + ]); + return { result: res.result.map((r) => toExchangeRow(r, partnerNameById)), total: res.totalCount }; + }, + + async retryExchange(id: string, { reset }: { reset: boolean }): Promise<{ id: string }> { + await post(`/xchanges/${id}/retry`, { reason: "Manual retry", reset }); + // Retry.cs returns null — look up the retry it just created (the newest + // xchange with retryFor == id) for a real id to hand back to the caller. + const res = await get>( + `/xchanges?filter=${encodeURIComponent(`RetryFor:1:${id}`)}&sort=StartedOn:2&size=1`, + ); + return { id: res.result[0]?.id ?? id }; + }, + + async bulkRetryExchanges(ids: string[], { reset }: { reset: boolean }): Promise<{ retried: number; skipped: number }> { + // BulkRetry.cs silently skips ids that already have a scheduled auto-retry + // and returns null — mirror its exact skip rule ourselves beforehand so we + // can report real counts back to the caller. + const idFilter = `Id:4:text|${ids.join("|")}`; + const current = await get>( + `/xchanges?filter=${encodeURIComponent(idFilter)}&size=${ids.length}`, + ); + // The Id filter also matches retryFor/aggregationXchangeId — narrow back + // down to exactly the requested ids. + const byId = new Map(current.result.filter((r) => ids.includes(r.id)).map((r) => [r.id, r])); + const skipped = ids.filter((id) => byId.get(id)?.scheduledRetryOn != null).length; + await post("/xchanges/bulkretry", { ids, reason: "Bulk retry", reset }); + return { retried: ids.length - skipped, skipped }; + }, + + async createExchange(input: { + target: "integration" | "informationType"; + integrationId?: number; + informationTypeId?: number; + data: string; + }): Promise<{ id: string }> { + const filter = + input.target === "integration" + ? `SubscriptionId:1:${input.integrationId}` + : `DocumentId:1:${input.informationTypeId}`; + await post("/xchanges", { + option: input.target === "integration" ? "SubscriberId" : "DocumentId", + subscriberId: input.target === "integration" ? input.integrationId : null, + documentId: input.target === "informationType" ? input.informationTypeId : null, + data: input.data, + }); + // Create.cs returns null too — look up the exchange it just created. When + // addressed at an information type, every matching integration gets its + // own exchange; we can only link to one, so take the newest. + const res = await get>( + `/xchanges?filter=${encodeURIComponent(filter)}&sort=StartedOn:2&size=1`, + ); + return { id: res.result[0]?.id ?? "" }; + }, + + async searchScheduledRetries(query: ScheduledRetryQuery): Promise> { + const res = await get>(`/delayedretries?${buildScheduledRetryQuery(query)}`); + return { result: res.result.map(toScheduledRetryRow), total: res.totalCount }; + }, + + async runScheduledRetryNow(id: string): Promise { + await post(`/delayedretries/${id}/runnow`, {}); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts new file mode 100644 index 00000000..0c5d25b7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts @@ -0,0 +1,211 @@ +import type { ApiClient } from "../client"; +import type { + ApiGateway, + ApiGatewayAttachment, + ApiGatewayDetail, + ApiGatewayRow, + BusGateway, + BusGatewayDetail, + BusGatewayRoute, + BusGatewayRow, + MatchGroup, +} from "../types"; +import { toMatchGroup, toRawMatchExpression, type RawMatchSpec } from "./matchExpression"; +import { get, post, request } from "./request"; + +// ——— backend shapes (camelCase over the wire) ——— +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawApiGatewayPartner { + partnerId: number; + subscriptionId: number; + partnerName: string; + subscriptionName: string; +} +interface RawApiGateway { + id: number; + name: string; + urlName: string; + partnersCount: number | null; + // Search's list projection includes this too (backend change made alongside + // this batch) — but keep it optional since Create's bare POST response has none. + partners: RawApiGatewayPartner[] | null; +} +interface RawBusGatewayRoute { + id: number; + subscriptionId: number; + subscriptionName: string | null; + partnerId: number | null; + partnerName: string | null; + matchExpression: RawMatchSpec | null; +} +interface RawBusGateway { + id: number; + name: string; + documentId: number; + documentName: string | null; + routesCount: number | null; + routes: RawBusGatewayRoute[] | null; +} + +const toApiGatewayAttachment = (p: RawApiGatewayPartner): ApiGatewayAttachment => ({ + partnerId: p.partnerId, + partnerName: p.partnerName, + integrationId: p.subscriptionId, + integrationName: p.subscriptionName, +}); + +const toApiGatewayRow = (raw: RawApiGateway): ApiGatewayRow => ({ + id: raw.id, + name: raw.name, + urlName: raw.urlName, + createdOn: "", + partnerCount: raw.partnersCount ?? raw.partners?.length ?? 0, + attachments: (raw.partners ?? []).map(toApiGatewayAttachment), +}); + +const toApiGatewayDetail = (raw: RawApiGateway): ApiGatewayDetail => ({ + id: raw.id, + name: raw.name, + urlName: raw.urlName, + createdOn: "", + attachments: (raw.partners ?? []).map(toApiGatewayAttachment), +}); + +const toBusGatewayRoute = (r: RawBusGatewayRoute): BusGatewayRoute => ({ + id: r.id, + integrationId: r.subscriptionId, + integrationName: r.subscriptionName ?? "", + partnerId: r.partnerId, + partnerName: r.partnerName, + matchExpression: toMatchGroup(r.matchExpression), +}); + +const toBusGatewayRow = (raw: RawBusGateway): BusGatewayRow => ({ + id: raw.id, + name: raw.name, + informationTypeId: raw.documentId, + createdOn: "", + informationTypeCode: raw.documentName ?? "UNKNOWN", + routeCount: raw.routesCount ?? raw.routes?.length ?? 0, + routes: (raw.routes ?? []).map(toBusGatewayRoute), +}); + +const toBusGatewayDetail = (raw: RawBusGateway): BusGatewayDetail => ({ + id: raw.id, + name: raw.name, + informationTypeId: raw.documentId, + createdOn: "", + informationTypeCode: raw.documentName ?? "UNKNOWN", + informationTypeName: raw.documentName ?? "Unknown", + routes: (raw.routes ?? []).map(toBusGatewayRoute), +}); + +export const gatewayMethods = { + // ——— API gateways ——— + + async listApiGateways(): Promise { + const res = await get>("/apigateways"); + return (res.result ?? []).map(toApiGatewayRow); + }, + + async getApiGateway(id: number): Promise { + return toApiGatewayDetail(await get(`/apigateways/${id}`)); + }, + + async createApiGateway({ name, urlName }: { name: string; urlName: string }): Promise { + const id = await post("/apigateways", { name, urlName }); + return { id, name, urlName, createdOn: "" }; + }, + + async updateApiGateway(id: number, changes: { name: string; urlName: string }): Promise { + await post(`/apigateways/${id}`, { name: changes.name, urlName: changes.urlName }); + return { id, name: changes.name, urlName: changes.urlName, createdOn: "" }; + }, + + async deleteApiGateway(id: number): Promise { + await request(`/apigateways/${id}`, { method: "DELETE" }); + }, + + async attachGatewayPartner(id: number, input: { partnerId: number; integrationId: number }): Promise { + await post(`/apigateways/${id}/addpartner`, { partnerId: input.partnerId, subscriptionId: input.integrationId }); + }, + + async updateGatewayAttachment(id: number, input: { partnerId: number; integrationId: number }): Promise { + // Not a plain POST to updatepartner: ApiGatewayPartner's PK is the composite + // (gatewayId, partnerId, subscriptionId), and the backend's UpdatePartner + // handler tries to mutate subscriptionId in place on a tracked entity — EF + // Core rejects changes to a key column. Remove-then-add sidesteps it. + await post(`/apigateways/${id}/removepartner`, { partnerId: input.partnerId }); + await post(`/apigateways/${id}/addpartner`, { partnerId: input.partnerId, subscriptionId: input.integrationId }); + }, + + async removeGatewayAttachment(id: number, partnerId: number): Promise { + await post(`/apigateways/${id}/removepartner`, { partnerId }); + }, + + // ——— bus gateways ——— + + async listBusGateways(): Promise { + const res = await get>("/busgateways"); + return (res.result ?? []).map(toBusGatewayRow); + }, + + async getBusGateway(id: number): Promise { + return toBusGatewayDetail(await get(`/busgateways/${id}`)); + }, + + async createBusGateway({ + name, + informationTypeId, + }: { + name: string; + informationTypeId: number; + }): Promise { + const id = await post("/busgateways", { name, documentId: informationTypeId }); + return { id, name, informationTypeId, createdOn: "" }; + }, + + async updateBusGateway(id: number, changes: { name: string }): Promise { + // The bound information type is fixed at creation — Update.cs silently + // ignores documentId — but the request DTO still requires a value, so + // fetch the current one to round-trip it rather than sending a bogus 0. + const current = await get(`/busgateways/${id}`); + await post(`/busgateways/${id}`, { name: changes.name, documentId: current.documentId }); + return { id, name: changes.name, informationTypeId: current.documentId, createdOn: "" }; + }, + + async deleteBusGateway(id: number): Promise { + await request(`/busgateways/${id}`, { method: "DELETE" }); + }, + + async addBusRoute( + id: number, + input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, + ): Promise { + await post(`/busgateways/${id}/addroute`, { + subscriptionId: input.integrationId, + partnerId: input.partnerId, + matchExpression: toRawMatchExpression(input.matchExpression), + }); + }, + + async updateBusRoute( + id: number, + routeId: number, + input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, + ): Promise { + await post(`/busgateways/${id}/updateroute`, { + routeId, + subscriptionId: input.integrationId, + partnerId: input.partnerId, + matchExpression: toRawMatchExpression(input.matchExpression), + }); + }, + + async removeBusRoute(id: number, routeId: number): Promise { + await post(`/busgateways/${id}/removeroute`, { routeId }); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/globalValues.ts b/SW.Bitween.Web/ClientApp/src/api/http/globalValues.ts new file mode 100644 index 00000000..fec08f2a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/globalValues.ts @@ -0,0 +1,129 @@ +import type { ApiClient } from "../client"; +import type { GlobalValuesSet, GlobalValuesSetDetail, GlobalValuesSetRow, IntegrationType, ValueSetUsage } from "../types"; +import { get, getEnrichment, post } from "./request"; + +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawValueSet { + id: string; + name: string; + values: Record | null; +} +interface RawKeyAndValue { + key: string; + value: string; +} +interface RawSubscriptionForUsage { + id: number; + name: string; + type: number | string; + mapperProperties: RawKeyAndValue[] | null; + handlerProperties: RawKeyAndValue[] | null; + receiverProperties: RawKeyAndValue[] | null; + validatorProperties: RawKeyAndValue[] | null; +} + +const SUB_TYPE_BY_NUM: Record = { + 1: "Internal", + 2: "ApiCall", + 4: "Receiving", + 8: "Aggregation", + 16: "GatewayApiCall", + 32: "BusGateway", +}; +const INTEGRATION_TYPES: IntegrationType[] = [ + "Receiving", + "GatewayApiCall", + "BusGateway", + "Internal", + "ApiCall", + "Aggregation", +]; +/** Enums may arrive as the numeric value or the name in any case. */ +const toIntegrationType = (t: number | string): IntegrationType => { + if (typeof t === "number") return SUB_TYPE_BY_NUM[t] ?? "Internal"; + return INTEGRATION_TYPES.find((k) => k.toLowerCase() === t.toLowerCase()) ?? "Internal"; +}; + +// GlobalAdapterValuesSet has no CreatedOn column on the backend. +const toRow = (r: RawValueSet): GlobalValuesSet => ({ + id: r.id, + name: r.name, + values: r.values ?? {}, + createdOn: "", +}); + +async function fetchAllSubscriptionsForUsage(): Promise { + const res = await getEnrichment>("/subscriptions", { result: [], totalCount: 0 }); + return res.result ?? []; +} + +/** + * Backend token resolution (StartupValuesFiller.Fill) splits on the first "." + * only and puts no character-class restriction on the set id or key, so match + * the same way here rather than a restrictive charset. + */ +const GLOBAL_TOKEN_RE = /\{\{globals\.([^.]+)\.([^}]+)\}\}/g; + +function globalKeysReferencedBy(sub: RawSubscriptionForUsage, setId: string): string[] { + const keys = new Set(); + const values = [ + ...(sub.mapperProperties ?? []), + ...(sub.handlerProperties ?? []), + ...(sub.receiverProperties ?? []), + ...(sub.validatorProperties ?? []), + ]; + for (const { value } of values) { + if (!value) continue; + for (const m of value.matchAll(GLOBAL_TOKEN_RE)) { + if (m[1] === setId) keys.add(m[2]); + } + } + return [...keys]; +} + +export const globalValuesMethods = { + async listValueSets(): Promise { + const [res, subs] = await Promise.all([ + get>("/globaladaptervaluessets"), + fetchAllSubscriptionsForUsage(), + ]); + return (res.result ?? []).map((r) => ({ + ...toRow(r), + usedByCount: subs.filter((s) => globalKeysReferencedBy(s, r.id).length > 0).length, + })); + }, + + async getValueSet(id: string): Promise { + const [r, subs] = await Promise.all([ + get(`/globaladaptervaluessets/${id}`), + fetchAllSubscriptionsForUsage(), + ]); + const usedBy: ValueSetUsage[] = subs + .map((s) => ({ + integrationSetup: { id: s.id, name: s.name, type: toIntegrationType(s.type) }, + keys: globalKeysReferencedBy(s, id), + })) + .filter((u) => u.keys.length > 0); + return { ...toRow(r), usedBy }; + }, + + async createValueSet(input: { id: string; name: string; values: Record }): Promise { + await post("/globaladaptervaluessets", { id: input.id, name: input.name, values: input.values }); + return { ...toRow(input), usedByCount: 0 }; + }, + + async updateValueSet( + id: string, + changes: { name: string; values: Record }, + ): Promise { + await post(`/globaladaptervaluessets/${id}`, { name: changes.name, values: changes.values }); + return { ...toRow({ id, ...changes }), usedByCount: 0 }; + }, + + async deleteValueSet(id: string): Promise { + await post(`/globaladaptervaluessets/${id}/delete`, {}); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts b/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts new file mode 100644 index 00000000..9b3dd3e7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts @@ -0,0 +1,51 @@ +import type { ApiClient } from "../client"; +import { NotWiredError } from "../types"; +import { adapterMethods } from "./adapters"; +import { dashboardMethods } from "./dashboard"; +import { documentMethods } from "./documents"; +import { exchangeMethods } from "./exchanges"; +import { gatewayMethods } from "./gateways"; +import { globalValuesMethods } from "./globalValues"; +import { integrationMethods } from "./integrations"; +import { mapperMethods } from "./mappers"; +import { notifierMethods } from "./notifiers"; +import { partnerMethods } from "./partners"; +import { queueHealthMethods } from "./queueHealth"; +import { retryPolicyMethods } from "./retryPolicies"; +import { sessionMethods } from "./session"; +import { settingsMethods } from "./settings"; +import { teamMethods } from "./team"; +import { workGroupMethods } from "./workGroups"; + +/** + * The single real client. Wired domains are merged in here; every other + * ApiClient method resolves to a rejected NotWiredError so its screen shows an + * honest "Not connected yet" state instead of fake data. Each batch adds its + * domain module to `wired` (see BACKEND_WIRING_PLAN §5–6). + */ +const wired: Partial = { + ...sessionMethods, + ...partnerMethods, + ...documentMethods, + ...globalValuesMethods, + ...workGroupMethods, + ...retryPolicyMethods, + ...integrationMethods, + ...adapterMethods, + ...gatewayMethods, + ...exchangeMethods, + ...queueHealthMethods, + ...dashboardMethods, + ...mapperMethods, + ...notifierMethods, + ...teamMethods, + ...settingsMethods, +}; + +export const httpClient: ApiClient = new Proxy(wired, { + get(target, prop, receiver) { + if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver); + // Anything not yet wired: a callable that rejects clearly, so `await api.x()` fails honestly. + return () => Promise.reject(new NotWiredError(prop)); + }, +}) as ApiClient; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts new file mode 100644 index 00000000..3e8907c5 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts @@ -0,0 +1,397 @@ +import type { ApiClient } from "../client"; +import { + ApiRequestError, + type Integration, + type IntegrationDetail, + type IntegrationInfo, + type IntegrationRow, + type IntegrationType, + type Schedule, +} from "../types"; +import { schedulesSummary } from "../../lib/schedules"; +import { documentMethods } from "./documents"; +import { exchangeMethods } from "./exchanges"; +import { gatewayMethods } from "./gateways"; +import { partnerMethods } from "./partners"; +import { get, post, request } from "./request"; +import { toMatchGroup, toRawMatchExpression, type RawMatchSpec } from "./matchExpression"; + +// ——— backend shapes (camelCase over the wire) ——— +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawKeyAndValue { + key: string; + value: string; +} +interface RawSchedule { + recurrence: Schedule["recurrence"]; + days: number; + hours: number; + minutes: number; + backwards: boolean; +} + +interface RawSubscription { + id?: number; + name: string; + documentId: number; + partnerId: number | null; + aggregationForId: number | null; + type: string; + handlerId: string | null; + mapperId: string | null; + receiverId: string | null; + validatorId: string | null; + inactive: boolean; + temporary: boolean; + categoryId: number | null; + handlerProperties: RawKeyAndValue[] | null; + mapperProperties: RawKeyAndValue[] | null; + receiverProperties: RawKeyAndValue[] | null; + validatorProperties: RawKeyAndValue[] | null; + documentFilter: RawKeyAndValue[] | null; + matchExpression: RawMatchSpec | null; + workGroupId: number | null; + retryPolicyId: number | null; + customRetryPolicy: unknown | null; + schedules: RawSchedule[] | null; + responseSubscriptionId: number | null; + responseMessageTypeName: string | null; + receiveOn: string | null; + aggregateOn: string | null; + pausedOn: string | null; + isRunning: boolean | null; + consecutiveFailures: number; + lastException: string | null; + aggregationTarget?: string; +} + +const SUB_TYPE_BY_NUM: Record = { + 1: "Internal", + 2: "ApiCall", + 4: "Receiving", + 8: "Aggregation", + 16: "GatewayApiCall", + 32: "BusGateway", +}; +const INTEGRATION_TYPES: IntegrationType[] = [ + "Receiving", + "GatewayApiCall", + "BusGateway", + "Internal", + "ApiCall", + "Aggregation", +]; +/** Enums serialize as their C# member name string, but guard the numeric case too. */ +const toIntegrationType = (t: number | string): IntegrationType => + typeof t === "number" + ? (SUB_TYPE_BY_NUM[t] ?? "Internal") + : (INTEGRATION_TYPES.find((k) => k.toLowerCase() === t.toLowerCase()) ?? "Internal"); + +// Empty-valued properties are dropped: an adapter property with no value means +// "not set", and keeping it would make a freshly-cleared field compare unequal to +// stored data that never had the key — leaving the Save bar up after an undo. +const toRecord = (kvs: RawKeyAndValue[] | null): Record => + Object.fromEntries((kvs ?? []).filter((kv) => kv.value !== "").map((kv) => [kv.key, kv.value])); +const toKvArray = (record: Record): RawKeyAndValue[] => + Object.entries(record).map(([key, value]) => ({ key, value })); + +const toSchedules = (raw: RawSchedule[] | null): Schedule[] => + (raw ?? []).map((s) => ({ + recurrence: s.recurrence, + days: s.days, + hours: s.hours, + minutes: s.minutes, + backwards: s.backwards, + })); +const toRawSchedules = (schedules: Schedule[]): RawSchedule[] => + schedules.map((s) => ({ + recurrence: s.recurrence, + days: s.days, + hours: s.hours, + minutes: s.minutes, + backwards: s.backwards, + })); + +function toIntegration(raw: RawSubscription, idOverride?: number): Integration { + return { + id: raw.id ?? idOverride!, + name: raw.name, + type: toIntegrationType(raw.type), + informationTypeId: raw.documentId, + partnerId: raw.partnerId, + enabled: !raw.inactive, + pausedOn: raw.pausedOn ?? null, + workGroupId: raw.workGroupId ?? null, + retryPolicyId: raw.retryPolicyId ?? null, + receiverId: raw.receiverId ?? null, + receiverProperties: toRecord(raw.receiverProperties), + validatorId: raw.validatorId ?? null, + validatorProperties: toRecord(raw.validatorProperties), + mapperId: raw.mapperId ?? null, + mapperProperties: toRecord(raw.mapperProperties), + handlerId: raw.handlerId ?? null, + handlerProperties: toRecord(raw.handlerProperties), + matchExpression: toMatchGroup(raw.matchExpression), + schedules: toSchedules(raw.schedules), + responseIntegrationId: raw.responseSubscriptionId ?? null, + responseMessageTypeName: raw.responseMessageTypeName ?? null, + aggregationForId: raw.aggregationForId ?? null, + isRunning: raw.isRunning ?? false, + nextReceiveOn: raw.receiveOn ?? null, + consecutiveFailures: raw.consecutiveFailures ?? 0, + lastException: raw.lastException ?? null, + // Subscription has no CreatedOn column on the backend. + createdOn: "", + }; +} + +async function fetchRaw(id: number): Promise { + const raw = await get(`/subscriptions/${id}`); + if (!raw) throw new ApiRequestError("NOT_FOUND", "This integration no longer exists."); + return raw; +} + +async function fetchAllRaw(): Promise { + const res = await get>("/subscriptions"); + return res.result ?? []; +} + +type UpdatableFields = Partial< + Pick< + Integration, + | "name" + | "enabled" + | "workGroupId" + | "retryPolicyId" + | "receiverId" + | "receiverProperties" + | "validatorId" + | "validatorProperties" + | "mapperId" + | "mapperProperties" + | "handlerId" + | "handlerProperties" + | "matchExpression" + | "schedules" + | "responseIntegrationId" + | "responseMessageTypeName" + > +>; + +/** + * POST /subscriptions/{id} replaces the whole record, and Update.cs's model + * (SubscriptionUpdate) carries several fields this UI never shows (categoryId, + * aggregationTarget, temporary, …) — omitting them would silently reset them + * to their type default. So every write reads the current full record first + * and splices `changes` on top of it, mirroring writePartner()'s pattern. + * + * Secret adapter property values arrive masked as the literal string + * "__private__" (Get.cs's PrivateSentinel); passed straight through unedited, + * Update.cs's own merge logic restores the real stored value — this file + * never needs to know about the sentinel itself. + */ +async function applyChanges(id: number, current: RawSubscription, changes: UpdatableFields): Promise { + await post(`/subscriptions/${id}`, { + name: changes.name ?? current.name, + documentId: current.documentId, + partnerId: current.partnerId, + aggregationForId: current.aggregationForId, + categoryId: current.categoryId, + inactive: changes.enabled !== undefined ? !changes.enabled : current.inactive, + workGroupId: changes.workGroupId !== undefined ? changes.workGroupId : current.workGroupId, + // This UI only ever assigns a named policy, never an inline one. + retryPolicyId: changes.retryPolicyId !== undefined ? changes.retryPolicyId : current.retryPolicyId, + customRetryPolicy: null, + receiverId: changes.receiverId !== undefined ? changes.receiverId : current.receiverId, + receiverProperties: toKvArray(changes.receiverProperties ?? toRecord(current.receiverProperties)), + validatorId: changes.validatorId !== undefined ? changes.validatorId : current.validatorId, + validatorProperties: toKvArray(changes.validatorProperties ?? toRecord(current.validatorProperties)), + mapperId: changes.mapperId !== undefined ? changes.mapperId : current.mapperId, + mapperProperties: toKvArray(changes.mapperProperties ?? toRecord(current.mapperProperties)), + handlerId: changes.handlerId !== undefined ? changes.handlerId : current.handlerId, + handlerProperties: toKvArray(changes.handlerProperties ?? toRecord(current.handlerProperties)), + documentFilter: current.documentFilter ?? [], + matchExpression: + changes.matchExpression !== undefined ? toRawMatchExpression(changes.matchExpression) : current.matchExpression, + schedules: changes.schedules !== undefined ? toRawSchedules(changes.schedules) : (current.schedules ?? []), + responseSubscriptionId: + changes.responseIntegrationId !== undefined ? changes.responseIntegrationId : current.responseSubscriptionId, + responseMessageTypeName: + changes.responseMessageTypeName !== undefined ? changes.responseMessageTypeName : current.responseMessageTypeName, + temporary: current.temporary, + aggregationTarget: current.aggregationTarget, + pausedOn: current.pausedOn, + receiveOn: current.receiveOn, + aggregateOn: current.aggregateOn, + consecutiveFailures: current.consecutiveFailures, + lastException: current.lastException, + }); +} + +export const integrationMethods = { + async listIntegrations(): Promise { + const rows = await fetchAllRaw(); + return rows.map((raw) => ({ + id: raw.id!, + name: raw.name, + type: toIntegrationType(raw.type), + partnerIds: raw.partnerId !== null ? [raw.partnerId] : [], + informationTypeId: raw.documentId, + workGroupId: raw.workGroupId ?? null, + retryPolicyId: raw.retryPolicyId ?? null, + // Requires scanning adapter properties for {{partner.KEY}}/{{globals…}} + // reference tokens — no backend endpoint for this; deferred. + partnerPropKeys: [], + globals: [], + })); + }, + + async listIntegrationRows(): Promise { + const [rows, infoTypes, partners] = await Promise.all([ + fetchAllRaw(), + documentMethods.listInformationTypes(), + partnerMethods.listPartners(), + ]); + const infoTypeById = new Map(infoTypes.map((t) => [t.id, t])); + const partnerById = new Map(partners.map((p) => [p.id, p])); + + return rows.map((raw) => { + const type = toIntegrationType(raw.type); + const infoType = infoTypeById.get(raw.documentId); + const partner = raw.partnerId !== null ? partnerById.get(raw.partnerId) : undefined; + const schedules = toSchedules(raw.schedules); + return { + id: raw.id!, + name: raw.name, + type, + informationTypeId: raw.documentId, + informationTypeCode: infoType?.code ?? infoType?.name ?? "", + // Gateway-derived partners (GatewayApiCall/BusGateway) land in Batch 3. + partners: partner ? [{ id: partner.id, name: partner.name }] : [], + enabled: !raw.inactive, + paused: raw.pausedOn !== null, + isRunning: raw.isRunning ?? false, + consecutiveFailures: raw.consecutiveFailures ?? 0, + lastException: raw.lastException ?? null, + // Search.cs can't select Schedules in this joined query without + // breaking SQL translation (Postgres date_part type mismatch), so + // schedules is always empty here — showing "No schedule" would be + // actively wrong for a job that has one. Leave it unset instead. + scheduleSummary: + schedules.length > 0 && (type === "Receiving" || type === "Aggregation") + ? schedulesSummary(schedules) + : undefined, + nextReceiveOn: raw.receiveOn ?? null, + createdOn: "", + }; + }); + }, + + async getIntegration(id: number): Promise { + const [raw, apiGateways, busGateways, recentExchanges] = await Promise.all([ + fetchRaw(id), + gatewayMethods.listApiGateways(), + gatewayMethods.listBusGateways(), + exchangeMethods.searchExchanges({ integrationId: id, offset: 0, limit: 8 }), + ]); + const infoType = await documentMethods.getInformationType(raw.documentId).catch(() => null); + return { + ...toIntegration(raw, id), + informationTypeCode: infoType?.code ?? infoType?.name ?? "", + informationTypeName: infoType?.name ?? "", + apiGatewayAttachments: apiGateways.flatMap((g) => + g.attachments + .filter((a) => a.integrationId === id) + .map((a) => ({ + gatewayId: g.id, + gatewayName: g.name, + urlName: g.urlName, + partnerId: a.partnerId, + partnerName: a.partnerName, + })), + ), + busGatewayRoutes: busGateways.flatMap((g) => + g.routes + .filter((r) => r.integrationId === id) + .map((r) => ({ gatewayId: g.id, gatewayName: g.name, partnerId: r.partnerId, partnerName: r.partnerName })), + ), + recentExchanges: recentExchanges.result.map((x) => ({ + id: x.id, + partnerName: x.partnerName ?? undefined, + informationTypeCode: x.informationTypeCode, + status: x.status, + on: x.startedOn, + })), + // Populated once notifiers and the trail (a distinct audit-log endpoint, + // deferred alongside the mapper editor/aggregation) are wired. + watchingNotifiers: [], + trail: [], + }; + }, + + async createIntegration(input: { + type: IntegrationType; + name: string; + informationTypeId: number; + receiverId?: string | null; + receiverProperties?: Record; + validatorId?: string | null; + validatorProperties?: Record; + mapperId?: string | null; + mapperProperties?: Record; + handlerId?: string | null; + handlerProperties?: Record; + schedules?: Schedule[]; + retryPolicyId?: number | null; + enabled?: boolean; + }): Promise { + // Create has no field for adapters/schedules/retry policy/enabled at all — + // every subscription is born Inactive with empty pipelines — so those all + // need an immediate follow-up update. + const id = await post("/subscriptions", { + name: input.name, + documentId: input.informationTypeId, + type: input.type, + partnerId: null, + aggregationForId: null, + }); + const current = await fetchRaw(id); + await applyChanges(id, current, { + receiverId: input.receiverId ?? null, + receiverProperties: input.receiverProperties ?? {}, + validatorId: input.validatorId ?? null, + validatorProperties: input.validatorProperties ?? {}, + mapperId: input.mapperId ?? null, + mapperProperties: input.mapperProperties ?? {}, + handlerId: input.handlerId ?? null, + handlerProperties: input.handlerProperties ?? {}, + schedules: input.schedules ?? [], + retryPolicyId: input.retryPolicyId ?? null, + enabled: input.enabled ?? false, + }); + return toIntegration(await fetchRaw(id), id); + }, + + async updateIntegration(id: number, changes: UpdatableFields): Promise { + const current = await fetchRaw(id); + await applyChanges(id, current, changes); + return toIntegration(await fetchRaw(id), id); + }, + + async deleteIntegration(id: number): Promise { + await request(`/subscriptions/${id}`, { method: "DELETE" }); + }, + + async pauseIntegration(id: number): Promise { + await post(`/subscriptions/${id}/pause`, {}); + return toIntegration(await fetchRaw(id), id); + }, + + async receiveNow(id: number): Promise { + await post(`/subscriptions/${id}/receivenow`, {}); + return toIntegration(await fetchRaw(id), id); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts b/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts new file mode 100644 index 00000000..68ebcf02 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts @@ -0,0 +1,18 @@ +import type { ApiClient } from "../client"; +import { post } from "./request"; + +interface RawMapperPreviewResponse { + outputJson: string | null; + error: string | null; +} + +export const mapperMethods = { + async previewMapping(input: { + scribanTemplate: string; + inputJson: string; + partnerId?: number | null; + }): Promise<{ outputJson: string | null; error: string | null }> { + const res = await post("/mappers", input); + return { outputJson: res.outputJson ?? null, error: res.error ?? null }; + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/matchExpression.ts b/SW.Bitween.Web/ClientApp/src/api/http/matchExpression.ts new file mode 100644 index 00000000..b05102b9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/matchExpression.ts @@ -0,0 +1,57 @@ +import type { MatchGroup, MatchNode } from "../types"; + +/** + * The backend's match tree is strictly binary (and/or each take exactly two + * operands) and uses snake_case type discriminators, unlike the frontend's + * n-ary MatchGroup. Shared by Subscriptions and BusGatewayRoute — both use the + * exact same backend type (`IPropertyMatchSpecification`). + */ +export type RawMatchSpec = + | { type: "one_of" | "not_one_of"; path: string; values: string[] | null } + | { type: "and" | "or"; left: RawMatchSpec; right: RawMatchSpec }; + +/** + * Fold the backend's binary and/or tree into the frontend's n-ary MatchGroup, + * flattening runs of the same operator so a flat group round-trips back flat + * instead of as a deeply right-nested tree. + */ +function toMatchNode(spec: RawMatchSpec): MatchNode { + if (!("left" in spec)) { + return { op: spec.type === "one_of" ? "oneOf" : "notOneOf", path: spec.path, values: spec.values ?? [] }; + } + const op = spec.type; + const children: MatchNode[] = []; + const collect = (s: RawMatchSpec) => { + if (s.type === op) { + collect(s.left); + collect(s.right); + } else { + children.push(toMatchNode(s)); + } + }; + collect(spec); + return { op, children }; +} + +export const toMatchGroup = (spec: RawMatchSpec | null): MatchGroup | null => { + if (!spec) return null; + const node = toMatchNode(spec); + // The backend can't represent a single-condition group (and/or always need + // two operands), so a lone condition arrives unwrapped and must be rewrapped + // here to satisfy the "root is always a group" contract. + return "children" in node ? node : { op: "and", children: [node] }; +}; + +/** Unfold an n-ary MatchGroup into the backend's binary tree, right-associatively. */ +function toBackendNode(node: MatchNode): RawMatchSpec | null { + if ("path" in node) { + return { type: node.op === "oneOf" ? "one_of" : "not_one_of", path: node.path, values: node.values }; + } + const parts = node.children.map(toBackendNode).filter((s): s is RawMatchSpec => s !== null); + if (parts.length === 0) return null; // empty group — matches everything, i.e. no constraint + if (parts.length === 1) return parts[0]; + return parts.reduceRight((right, left) => ({ type: node.op, left, right })); +} + +export const toRawMatchExpression = (group: MatchGroup | null): RawMatchSpec | null => + group ? toBackendNode(group) : null; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts b/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts new file mode 100644 index 00000000..d26f242d --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts @@ -0,0 +1,107 @@ +import type { ApiClient } from "../client"; +import { ApiRequestError, type NotificationEntry, type Notifier, type NotifierDetail } from "../types"; +import { get, post } from "./request"; + +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawKeyAndValue { + key: string; + value: string; +} +interface RawNotifier { + id: number; + name: string; + inactive: boolean; + handlerId: string | null; + handlerProperties: RawKeyAndValue[] | null; + runOnSuccessfulResult: boolean; + runOnBadResult: boolean; + runOnFailedResult: boolean; + runOnSubscriptions: { id: number; name: string | null }[] | null; +} +interface RawNotification { + xchangeId: string; + success: boolean; + exception: string | null; + finishedOn: string; +} +// Empty-valued properties are dropped: an adapter property with no value means +// "not set", and keeping it would make a freshly-cleared field compare unequal to +// stored data that never had the key — leaving the Save bar up after an undo. +const toRecord = (kvs: RawKeyAndValue[] | null): Record => + Object.fromEntries((kvs ?? []).filter((kv) => kv.value !== "").map((kv) => [kv.key, kv.value])); +const toKvArray = (record: Record): RawKeyAndValue[] => + Object.entries(record).map(([key, value]) => ({ key, value })); + +const toNotifier = (r: RawNotifier): Notifier => ({ + id: r.id, + name: r.name, + enabled: !r.inactive, + onFailed: r.runOnFailedResult, + onBadResult: r.runOnBadResult, + onSuccess: r.runOnSuccessfulResult, + channelId: r.handlerId ?? "", + channelProperties: toRecord(r.handlerProperties), + integrationIds: (r.runOnSubscriptions ?? []).map((s) => s.id), + createdOn: "", +}); + +async function fetchRecentNotifications(notifierName: string): Promise { + const res = await get>( + `/notifications?filter=${encodeURIComponent(`NotifierName:1:${notifierName}`)}`, + ); + return (res.result ?? []).map((n) => ({ + xchangeId: n.xchangeId, + success: n.success, + exception: n.exception ?? undefined, + on: n.finishedOn, + })); +} + +async function fetchDetail(id: number): Promise { + const raw = await get(`/notifiers/${id}`); + if (!raw) throw new ApiRequestError("NOT_FOUND", "This notifier no longer exists."); + const notifier = toNotifier(raw); + return { ...notifier, recentNotifications: await fetchRecentNotifications(notifier.name) }; +} + +export const notifierMethods = { + async listNotifiers(): Promise { + const res = await get>("/notifiers"); + return Promise.all((res.result ?? []).map((r) => fetchDetail(r.id))); + }, + + getNotifier: fetchDetail, + + async createNotifier({ name }: { name: string }): Promise { + const id = await post("/notifiers", { name }); + return { + id, + name, + enabled: true, + onFailed: false, + onBadResult: false, + onSuccess: false, + channelId: "", + channelProperties: {}, + integrationIds: [], + createdOn: "", + }; + }, + + async updateNotifier(id: number, changes: Omit): Promise { + await post(`/notifiers/${id}`, { + name: changes.name, + runOnSuccessfulResult: changes.onSuccess, + runOnBadResult: changes.onBadResult, + runOnFailedResult: changes.onFailed, + handlerId: changes.channelId, + inactive: !changes.enabled, + handlerProperties: toKvArray(changes.channelProperties), + runOnSubscriptions: changes.integrationIds.map((subscriptionId) => ({ id: subscriptionId })), + }); + return { id, createdOn: "", ...changes }; + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/partners.ts b/SW.Bitween.Web/ClientApp/src/api/http/partners.ts new file mode 100644 index 00000000..ca4d82b4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/partners.ts @@ -0,0 +1,183 @@ +import type { ApiClient } from "../client"; +import { ApiRequestError, type IntegrationType, type Partner, type PartnerDetail, type PartnerRow } from "../types"; +import { exchangeMethods } from "./exchanges"; +import { gatewayMethods } from "./gateways"; +import { matchSummary } from "../../lib/match"; +import { get, post, request } from "./request"; + +// The built-in SYSTEM partner (Partner.SystemId) can't be renamed or deleted. +const SYSTEM_PARTNER_ID = 1; + +// ——— backend shapes (camelCase over the wire) ——— +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawPartnerRow { + id: number; + name: string; + subscriptionsCount: number; + keys: number; + propertyKeys: string[] | null; +} +interface RawKeyAndValue { + key: string; + value: string; +} +interface RawSubscriptionRef { + id: number; + name: string; + type: number | string; +} +interface RawPartnerDetail { + name: string; + apiCredentials: RawKeyAndValue[] | null; + subscriptions: RawSubscriptionRef[] | null; + adapterProperties: Record | null; +} + +// GET masks keys as `...(hidden)`; recover the visible prefix. +const MASK_SUFFIX = "...(hidden)"; +const keyPrefixOf = (masked: string): string => + masked.endsWith(MASK_SUFFIX) ? masked.slice(0, -MASK_SUFFIX.length) : masked; + +const SUB_TYPE_BY_NUM: Record = { + 1: "Internal", + 2: "ApiCall", + 4: "Receiving", + 8: "Aggregation", + 16: "GatewayApiCall", + 32: "BusGateway", +}; +const INTEGRATION_TYPES: IntegrationType[] = [ + "Receiving", + "GatewayApiCall", + "BusGateway", + "Internal", + "ApiCall", + "Aggregation", +]; +/** Enums may arrive as the numeric value or the name in any case. */ +const toIntegrationType = (t: number | string): IntegrationType => { + if (typeof t === "number") return SUB_TYPE_BY_NUM[t] ?? "Internal"; + return INTEGRATION_TYPES.find((k) => k.toLowerCase() === t.toLowerCase()) ?? "Internal"; +}; + +async function requireDetail(id: number): Promise { + const d = await get(`/partners/${id}`); + if (!d) throw new ApiRequestError("NOT_FOUND", "This partner no longer exists."); + return d; +} + +/** + * `POST /partners/{id}` REPLACES the whole credential set, so every write must + * carry the full list. The backend matches credentials by name and keeps the + * stored secret, so re-sending the masked values is safe (verified against the + * live DB); omitting one revokes it. `mutate` produces the next list. + */ +async function writePartner( + id: number, + d: RawPartnerDetail, + patch: { name?: string; adapterProperties?: Record }, + mutate: (creds: RawKeyAndValue[]) => RawKeyAndValue[] = (c) => c, +): Promise { + const name = patch.name ?? d.name; + const adapterProperties = patch.adapterProperties ?? d.adapterProperties ?? {}; + const apiCredentials = mutate((d.apiCredentials ?? []).map((c) => ({ key: c.key, value: c.value }))); + await post(`/partners/${id}`, { name, adapterProperties, apiCredentials }); + return { id, name, adapterProperties, isSystem: id === SYSTEM_PARTNER_ID, createdOn: "" }; +} + +export const partnerMethods = { + async listPartners(): Promise { + const res = await get>("/partners"); + return (res.result ?? []).map((p) => ({ + id: p.id, + name: p.name, + // The list endpoint sends property names, never their values — they can be + // secrets. Anything needing values must fetch the partner's detail. + adapterProperties: {}, + propertyKeys: p.propertyKeys ?? [], + isSystem: p.id === SYSTEM_PARTNER_ID, + createdOn: "", + credentialCount: p.keys, + usedByCount: p.subscriptionsCount, + })); + }, + + /** Light single-field fetch for the mapper editor's test-partner selector — avoids getPartner's gateway/exchange lookups. */ + async getPartnerAdapterProperties(id: number): Promise> { + const d = await requireDetail(id); + return d.adapterProperties ?? {}; + }, + + async getPartner(id: number): Promise { + const [d, apiGateways, busGateways, recentExchanges] = await Promise.all([ + requireDetail(id), + gatewayMethods.listApiGateways(), + gatewayMethods.listBusGateways(), + exchangeMethods.searchExchanges({ partnerId: id, offset: 0, limit: 8 }), + ]); + return { + id, + name: d.name, + adapterProperties: d.adapterProperties ?? {}, + isSystem: id === SYSTEM_PARTNER_ID, + createdOn: "", + apiCredentials: (d.apiCredentials ?? []).map((c) => ({ + name: c.key, + keyPrefix: keyPrefixOf(c.value), + createdOn: "", + })), + integrationSetups: (d.subscriptions ?? []).map((s) => ({ + id: s.id, + name: s.name, + type: toIntegrationType(s.type), + })), + apiGateways: apiGateways + .filter((g) => g.attachments.some((a) => a.partnerId === id)) + .map((g) => ({ gatewayId: g.id, gatewayName: g.name, urlName: g.urlName })), + busGatewayRoutes: busGateways.flatMap((g) => + g.routes + .filter((r) => r.partnerId === id) + .map((r) => ({ gatewayId: g.id, gatewayName: g.name, matchExpression: matchSummary(r.matchExpression) })), + ), + recentExchanges: recentExchanges.result.map((x) => ({ + id: x.id, + informationTypeCode: x.informationTypeCode, + status: x.status, + on: x.startedOn, + })), + }; + }, + + async createPartner({ name }: { name: string }): Promise { + const id = await post("/partners", { name }); + return { id, name, adapterProperties: {}, isSystem: false, createdOn: "" }; + }, + + async updatePartner( + id: number, + changes: { name?: string; adapterProperties?: Record }, + ): Promise { + return writePartner(id, await requireDetail(id), changes); + }, + + async deletePartner(id: number): Promise { + await request(`/partners/${id}`, { method: "DELETE" }); + }, + + async addPartnerCredential(id: number, name: string): Promise<{ key: string }> { + const key = await get("/partners/generatekey"); + const d = await requireDetail(id); + if ((d.apiCredentials ?? []).some((c) => c.key.toLowerCase() === name.trim().toLowerCase())) + throw new ApiRequestError("NAME_TAKEN", "This partner already has a key with that name."); + await writePartner(id, d, {}, (creds) => [...creds, { key: name.trim(), value: key }]); + return { key }; + }, + + async revokePartnerCredential(id: number, name: string): Promise { + const d = await requireDetail(id); + await writePartner(id, d, {}, (creds) => creds.filter((c) => c.key !== name)); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/queueHealth.ts b/SW.Bitween.Web/ClientApp/src/api/http/queueHealth.ts new file mode 100644 index 00000000..4d04bf8c --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/queueHealth.ts @@ -0,0 +1,138 @@ +import type { ApiClient } from "../client"; +import type { QueueHealthSnapshot, QueueSeverity } from "../types"; +import { workGroupMethods } from "./workGroups"; +import { get } from "./request"; + +// ——— backend shapes (camelCase over the wire; severities are PascalCase strings) ——— +type RawSeverity = "Info" | "Warning" | "Critical"; +interface RawSummary { + totalConsumers: number; + unhealthyConsumers: number; + disconnectedConsumers: number; + totalQueueDepth: number; + totalRetryBacklog: number; + totalDeadLetterBacklog: number; + totalIncomingRate: number; + totalAckRate: number; + lastUpdatedUtc: string; +} +interface RawConsumer { + name: string; + messageName: string; + queueName: string; + totalNodes: number; + processingCount: number; + queueCount: number; + retryCount: number; + failedCount: number; + priority: number; + prefetch: number; + incomingRate: number; + ackRate: number; + isBackpressured: boolean; + healthStatus: RawSeverity; +} +interface RawRetryAnalysis { + consumerName: string; + queueName: string; + retryBacklog: number; + incomingRate: number; + ackRate: number; + severity: RawSeverity; +} +interface RawDeadLetter { + consumerName: string; + deadLetterQueueName: string; + deadLetterCount: number; + lastExceptionType: string | null; + lastExceptionMessage: string | null; + lastFailedAt: string | null; +} +interface RawAlert { + severity: RawSeverity; + title: string; + detail: string; + queueName: string; + timestampUtc: string; +} + +const toSeverity = (s: RawSeverity): QueueSeverity => (s === "Critical" ? "critical" : s === "Warning" ? "warning" : "healthy"); + +export const queueHealthMethods = { + async getQueueHealth(): Promise { + const [summary, consumers, retries, deadLetters, alerts, workGroups] = await Promise.all([ + get("/ops/summary"), + get("/ops/consumers"), + get("/ops/retries"), + get("/ops/deadletters"), + get("/ops/alerts"), + workGroupMethods.listWorkGroups(), + ]); + + // The ops endpoints don't expose workGroupId directly — derive it from the + // consumer's messageName, which for a grouped consumer is always + // WorkGroup.GetBusMessageName() == `${id}${busMessageName}`. + const workGroupIdByMessageName = new Map( + workGroups.map((wg) => [`${wg.id}${wg.busMessageName}`.toLowerCase(), wg.id]), + ); + + return { + summary: { + totalConsumers: summary.totalConsumers, + unhealthyConsumers: summary.unhealthyConsumers, + disconnectedConsumers: summary.disconnectedConsumers, + totalQueueDepth: summary.totalQueueDepth, + totalRetryBacklog: summary.totalRetryBacklog, + totalDeadLetterBacklog: summary.totalDeadLetterBacklog, + totalIncomingRate: summary.totalIncomingRate, + totalAckRate: summary.totalAckRate, + lastUpdated: summary.lastUpdatedUtc, + }, + consumers: consumers.map((c) => ({ + name: c.name, + messageName: c.messageName, + queueName: c.queueName, + workGroupId: workGroupIdByMessageName.get(c.messageName.toLowerCase()) ?? null, + totalNodes: c.totalNodes, + processingCount: c.processingCount, + queueCount: c.queueCount, + retryCount: c.retryCount, + failedCount: c.failedCount, + priority: c.priority, + prefetch: c.prefetch, + incomingRate: c.incomingRate, + ackRate: c.ackRate, + isBackpressured: c.isBackpressured, + health: toSeverity(c.healthStatus), + })), + retryBacklog: retries.map((r) => ({ + consumerName: r.consumerName, + queueName: r.queueName, + retryBacklog: r.retryBacklog, + incomingRate: r.incomingRate, + ackRate: r.ackRate, + severity: toSeverity(r.severity), + })), + deadLetters: deadLetters.map((d) => ({ + consumerName: d.consumerName, + queueName: d.deadLetterQueueName, + count: d.deadLetterCount, + lastExceptionType: d.lastExceptionType, + lastExceptionMessage: d.lastExceptionMessage, + lastFailedAt: d.lastFailedAt, + })), + // The frontend's alert severity is narrower ("warning"|"critical") than + // the backend's ("Info"|"Warning"|"Critical") — informational alerts + // aren't alerts from the UI's point of view, so drop them. + alerts: alerts + .filter((a) => a.severity !== "Info") + .map((a) => ({ + severity: a.severity === "Critical" ? ("critical" as const) : ("warning" as const), + title: a.title, + detail: a.detail, + queueName: a.queueName, + on: a.timestampUtc, + })), + }; + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/request.ts b/SW.Bitween.Web/ClientApp/src/api/http/request.ts new file mode 100644 index 00000000..67214946 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/request.ts @@ -0,0 +1,154 @@ +import { ApiRequestError } from "../types"; + +/** + * All endpoints live under /api (UrlPrefix="api"). + * The SPA is served from the same origin, so this stays relative and cookies flow. + */ +export const API_BASE = "/api"; + +/** The Jwt is kept in localStorage; the refresh token is an HttpOnly cookie JS never sees. */ +const TOKEN_KEY = "access_token"; + +export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); +export const setToken = (jwt: string): void => localStorage.setItem(TOKEN_KEY, jwt); +export const clearToken = (): void => localStorage.removeItem(TOKEN_KEY); + +/** Backend serializes camelCase; login returns `{ jwt }`. */ +const readJwt = (data: unknown): string | null => + (data as { jwt?: string; Jwt?: string })?.jwt ?? (data as { Jwt?: string })?.Jwt ?? null; + +let refreshInFlight: Promise | null = null; + +/** + * Silent refresh: POST /accounts/login with an empty body — the HttpOnly + * refresh_token cookie alone re-issues a Jwt. Returns the new token, or null + * when the cookie is missing/expired. Bypasses `request()` to avoid recursion, + * and dedupes concurrent callers behind one in-flight promise. + */ +export function silentRefresh(): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + const res = await fetch(`${API_BASE}/accounts/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: "{}", + }); + if (!res.ok) return null; + const jwt = readJwt(await res.json().catch(() => null)); + if (jwt) setToken(jwt); + else clearToken(); + return jwt; + } catch { + return null; + } + })(); + void refreshInFlight.finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +/** Pull a human message + best-effort code out of a backend error response. */ +async function toApiError(res: Response): Promise { + const text = await res.text().catch(() => ""); + let body: unknown = text; + try { + body = text ? JSON.parse(text) : ""; + } catch { + /* leave as text */ + } + + // 404 → the message is a bare JSON string. + if (typeof body === "string" && body) + return new ApiRequestError(res.status === 404 ? "NOT_FOUND" : "ERROR", body); + + // Framework-level errors (415, unhandled 500, …) come as ASP.NET ProblemDetails: + // { type, title, status, traceId }. Prefer its human `title`. + if (body && typeof body === "object" && "title" in body && "status" in body) { + const pd = body as { title?: string; status?: number }; + return new ApiRequestError(`HTTP_${pd.status ?? res.status}`, pd.title || `Request failed (${res.status}).`); + } + + // 400 → ASP.NET SerializableError: { key: [msg, ...] } (key is the validation + // code for SWValidationException, or the exception type name otherwise). + if (body && typeof body === "object") { + const [code, value] = Object.entries(body as Record)[0] ?? []; + const message = Array.isArray(value) ? String(value[0]) : String(value ?? ""); + if (code) return new ApiRequestError(code, message || "Request failed."); + } + + return new ApiRequestError("ERROR", `Request failed (${res.status}).`); +} + +export interface RequestOptions { + method?: "GET" | "POST" | "DELETE"; + body?: unknown; + /** Internal: prevents the 401 → refresh → retry loop from recursing. */ + _retried?: boolean; +} + +/** + * The one fetch helper every wired method goes through: prefixes the base, + * attaches `Authorization: Bearer `, includes credentials so the refresh + * cookie rides along, and on 401 attempts a single silent refresh + retry. + */ +export async function request(path: string, opts: RequestOptions = {}): Promise { + const token = getToken(); + const method = opts.method ?? "GET"; + // Backend command handlers (POST) bind a JSON body, so they always need + // `Content-Type: application/json` — even a body-less command like logout. + // Without it the framework rejects the call with 415 before the handler runs; + // an empty `{}` satisfies it. + const sendJson = method === "POST"; + const res = await fetch(`${API_BASE}${path}`, { + method, + credentials: "include", + headers: { + ...(sendJson ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: sendJson ? JSON.stringify(opts.body ?? {}) : undefined, + }); + + if (res.status === 401 && !opts._retried) { + const refreshed = await silentRefresh(); + if (refreshed) return request(path, { ...opts, _retried: true }); + clearToken(); + throw new ApiRequestError("UNAUTHENTICATED", "Your session has ended. Please sign in again."); + } + + if (!res.ok) throw await toApiError(res); + + if (res.status === 204) return undefined as T; + const text = await res.text(); + if (!text) return undefined as T; + // Some endpoints return a bare string as text/plain (e.g. /partners/generatekey), + // which isn't valid JSON — only parse when the response actually is JSON. + const isJson = res.headers.get("content-type")?.includes("application/json") ?? false; + return (isJson ? JSON.parse(text) : text) as T; +} + +export const get = (path: string): Promise => request(path); +export const post = (path: string, body?: unknown): Promise => + request(path, { method: "POST", body }); + +/** + * For a secondary read that only enriches a page — the "used by" counts, which come from another + * area's list. Those are permission-guarded in their own right, so a role that can see this page + * but not that area would otherwise take the whole page down with it. The enrichment is worth + * losing; the page isn't. Only refusals are swallowed, so a real outage still surfaces. + */ +export async function getEnrichment(path: string, fallback: T): Promise { + try { + return await get(path); + } catch (e) { + // A refusal for *this* read only. UNAUTHENTICATED deliberately isn't swallowed: that one means + // the session itself is gone, and the app needs to hear about it. + const code = e instanceof ApiRequestError ? e.code : ""; + if (code === "HTTP_401" || code === "HTTP_403") return fallback; + throw e; + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts new file mode 100644 index 00000000..06b9c41a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -0,0 +1,213 @@ +import type { ApiClient } from "../client"; +import { + ApiRequestError, + type IntegrationType, + type RetryDelay, + type RetryGroup, + type RetryPolicy, + type RetryPolicyDetail, + type RetryPolicyListRow, + type RetryResultType, + type RetryTestAttempt, +} from "../types"; +import { get, getEnrichment, post, request } from "./request"; + +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawRetryPolicyRow { + id: number; + name: string; + groupCount: number; +} +interface RawRetryPolicy { + name: string; + groups: RawRetryGroup[] | null; +} +interface RawSubscriptionRef { + id: number; + name: string; + type: number | string; +} + +const SUB_TYPE_BY_NUM: Record = { + 1: "Internal", + 2: "ApiCall", + 4: "Receiving", + 8: "Aggregation", + 16: "GatewayApiCall", + 32: "BusGateway", +}; +const INTEGRATION_TYPES: IntegrationType[] = [ + "Receiving", + "GatewayApiCall", + "BusGateway", + "Internal", + "ApiCall", + "Aggregation", +]; +/** Enums may arrive as the numeric value or the name in any case. */ +const toIntegrationType = (t: number | string): IntegrationType => { + if (typeof t === "number") return SUB_TYPE_BY_NUM[t] ?? "Internal"; + return INTEGRATION_TYPES.find((k) => k.toLowerCase() === t.toLowerCase()) ?? "Internal"; +}; + +async function fetchSubscriptionsByRetryPolicy(retryPolicyId: number): Promise { + const res = await get>( + `/subscriptions?filter=${encodeURIComponent(`RetryPolicyId:1:${retryPolicyId}`)}`, + ); + return res.result ?? []; +} + +// The backend's DelayStrategy keeps durations in milliseconds; the UI works in seconds. +type RawDelayStrategy = + | { type: "fixed"; delayMs: number } + | { type: "linear"; initialDelayMs: number; incrementMs: number } + | { type: "exponential"; initialDelayMs: number; multiplier: number; maxDelayMs: number }; +interface RawRetryBudget { + maxAttemptsPerError: number; + maxAttemptsTotal: number; + delayStrategy: RawDelayStrategy; +} +interface RawRetryGroup extends Omit { + budget?: RawRetryBudget | null; +} +interface RawTestAttempt { + attemptNumber: number; + matchedGroupName: string | null; + shouldRetry: boolean; + delaySeconds: number | null; + reason: string; +} + +const toDelay = (d: RawDelayStrategy): RetryDelay => { + switch (d.type) { + case "fixed": + return { type: "fixed", delaySeconds: d.delayMs / 1000 }; + case "linear": + return { type: "linear", initialSeconds: d.initialDelayMs / 1000, incrementSeconds: d.incrementMs / 1000 }; + case "exponential": + return { + type: "exponential", + initialSeconds: d.initialDelayMs / 1000, + multiplier: d.multiplier, + maxSeconds: d.maxDelayMs / 1000, + }; + } +}; + +const toRawDelay = (d: RetryDelay): RawDelayStrategy => { + switch (d.type) { + case "fixed": + return { type: "fixed", delayMs: Math.round(d.delaySeconds * 1000) }; + case "linear": + return { + type: "linear", + initialDelayMs: Math.round(d.initialSeconds * 1000), + incrementMs: Math.round(d.incrementSeconds * 1000), + }; + case "exponential": + return { + type: "exponential", + initialDelayMs: Math.round(d.initialSeconds * 1000), + multiplier: d.multiplier, + maxDelayMs: Math.round(d.maxSeconds * 1000), + }; + } +}; + +const toGroup = (g: RawRetryGroup): RetryGroup => ({ + ...g, + budget: g.budget + ? { + maxAttemptsPerError: g.budget.maxAttemptsPerError, + maxAttemptsTotal: g.budget.maxAttemptsTotal, + delay: toDelay(g.budget.delayStrategy), + } + : undefined, +}); + +const toRawGroup = (g: RetryGroup): RawRetryGroup => ({ + ...g, + budget: g.budget + ? { + maxAttemptsPerError: g.budget.maxAttemptsPerError, + maxAttemptsTotal: g.budget.maxAttemptsTotal, + delayStrategy: toRawDelay(g.budget.delay), + } + : undefined, +}); + +async function fetchDetail(id: number): Promise { + const [r, subs] = await Promise.all([ + get(`/retrypolicies/${id}`), + fetchSubscriptionsByRetryPolicy(id), + ]); + if (!r) throw new ApiRequestError("NOT_FOUND", "This retry policy no longer exists."); + return { + id, + name: r.name, + groups: (r.groups ?? []).map(toGroup), + createdOn: "", + integrations: subs.map((s) => ({ id: s.id, name: s.name, type: toIntegrationType(s.type) })), + }; +} + +export const retryPolicyMethods = { + async listRetryPolicies(): Promise { + const [res, subs] = await Promise.all([ + get>("/retrypolicies"), + getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), + ]); + const countByRetryPolicy = new Map(); + for (const s of subs.result ?? []) { + if (s.retryPolicyId == null) continue; + countByRetryPolicy.set(s.retryPolicyId, (countByRetryPolicy.get(s.retryPolicyId) ?? 0) + 1); + } + return (res.result ?? []).map((p) => ({ + id: p.id, + name: p.name, + groupCount: p.groupCount, + createdOn: "", + usedByCount: countByRetryPolicy.get(p.id) ?? 0, + })); + }, + + getRetryPolicy: fetchDetail, + + async createRetryPolicy({ name }: { name: string }): Promise { + const id = await post("/retrypolicies", { name, groups: [] }); + return { id, name, groups: [], createdOn: "" }; + }, + + async updateRetryPolicy(id: number, changes: { name: string; groups: RetryGroup[] }): Promise { + await post(`/retrypolicies/${id}`, { name: changes.name, groups: changes.groups.map(toRawGroup) }); + return { id, name: changes.name, groups: changes.groups, createdOn: "" }; + }, + + async deleteRetryPolicy(id: number): Promise { + await request(`/retrypolicies/${id}`, { method: "DELETE" }); + }, + + async testRetryPolicy(input: { + groups: RetryGroup[]; + resultType: RetryResultType; + content: string; + attempts: number; + }): Promise { + const res = await post<{ attempts: RawTestAttempt[] }>("/retrypolicies/test", { + groups: input.groups.map(toRawGroup), + resultType: input.resultType, + content: input.content, + attemptsToSimulate: input.attempts, + }); + return (res.attempts ?? []).map((a) => ({ + attempt: a.attemptNumber, + shouldRetry: a.shouldRetry, + delaySeconds: a.delaySeconds ?? undefined, + matchedGroup: a.matchedGroupName ?? undefined, + reason: a.reason, + })); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/session.ts b/SW.Bitween.Web/ClientApp/src/api/http/session.ts new file mode 100644 index 00000000..87e8df01 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/session.ts @@ -0,0 +1,114 @@ +import { ApiRequestError, type Session, type User } from "../types"; +import { getAppConfig } from "./appConfig"; +import { clearToken, get, getToken, post, setToken } from "./request"; + +/** GET /accounts/profile — camelCase ProfileModel. */ +interface Profile { + id: number; + email: string; + name: string; + /** Legacy coarse role, kept for older API clients. Authorization uses `permissions`. */ + role: string; + disabled: boolean; + createdOn: string; + roles: { id: number; name: string }[] | null; + permissions: string[] | null; +} + +/** POST /accounts/login → { jwt }. */ +interface LoginResult { + jwt: string; +} + +const buildSession = (profile: Profile): Session => { + const user: User = { + id: String(profile.id), + displayName: profile.name, + email: profile.email, + roleIds: (profile.roles ?? []).map((r) => String(r.id)), + status: profile.disabled ? "disabled" : "active", + microsoftLinked: false, + createdOn: profile.createdOn, + }; + return { + user, + roles: (profile.roles ?? []).map((r) => ({ id: String(r.id), name: r.name })), + // Resolved server-side from the user's roles, so a revoked role takes effect on next load. + permissions: profile.permissions ?? [], + }; +}; + +const loadSession = async (): Promise => buildSession(await get("/accounts/profile")); + +export const sessionMethods = { + async getSession(): Promise { + // No stored Jwt → anonymous; don't probe the backend (an expired token still + // gets refreshed via cookie inside request() on its 401). The token is only + // cleared on logout or an unrecoverable 401, so returning users keep it. + if (!getToken()) return null; + try { + return await loadSession(); + } catch { + // Token invalid and no refresh cookie → signed out. Show login, don't fake it. + return null; + } + }, + + async login(email: string, password: string): Promise { + const { jwt } = await post("/accounts/login", { + Username: email, + Password: password, + }); + setToken(jwt); + return loadSession(); + }, + + async loginWithMicrosoft(): Promise { + const cfg = await getAppConfig(); + if (!cfg.msalClientId) + throw new ApiRequestError("MS_NOT_CONFIGURED", "Microsoft sign-in isn't configured."); + + // Lazy so MSAL stays out of the initial bundle. + const { PublicClientApplication } = await import("@azure/msal-browser"); + const msal = new PublicClientApplication({ + auth: { + clientId: cfg.msalClientId, + ...(cfg.msalTenantId + ? { authority: `https://login.microsoftonline.com/${cfg.msalTenantId}` } + : {}), + }, + }); + await msal.initialize(); + const result = await msal.loginPopup({ + ...(cfg.msalRedirectUri ? { redirectUri: cfg.msalRedirectUri } : {}), + scopes: ["openid"], + }); + if (!result.idToken) + throw new ApiRequestError("MS_LOGIN_FAILED", "Microsoft didn't return a sign-in token."); + + const { jwt } = await post("/accounts/login", { MsToken: result.idToken }); + setToken(jwt); + return loadSession(); + }, + + async logout(): Promise { + try { + await post("/accounts/logout"); + } finally { + clearToken(); + } + }, + + async updateProfile(changes: { displayName: string }): Promise { + const current = await loadSession(); + await post(`/accounts/${current.user.id}`, { name: changes.displayName }); + return loadSession(); + }, + + async changePassword(currentPassword: string, newPassword: string): Promise { + await post("/accounts/changePassword", { + oldPassword: currentPassword, + newPassword, + }); + }, +}; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/settings.ts b/SW.Bitween.Web/ClientApp/src/api/http/settings.ts new file mode 100644 index 00000000..b41d1817 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/settings.ts @@ -0,0 +1,21 @@ +import type { ApiClient } from "../client"; +import type { SettingRow } from "../types"; +import { get, post, request } from "./request"; + +export const settingsMethods = { + /** + * The backend owns the catalog (label, section, kind, default, secret), so rows arrive + * ready to render — including which section each belongs to and, for secrets, only + * whether a value is set. + */ + listSettings(): Promise { + return get("/settings"); + }, + + /** A null value resets the setting, which is a DELETE rather than a write. */ + async updateSetting(key: string, value: string | null): Promise { + const path = `/settings/${encodeURIComponent(key)}`; + if (value === null) await request(path, { method: "DELETE" }); + else await post(path, { value }); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/team.ts b/SW.Bitween.Web/ClientApp/src/api/http/team.ts new file mode 100644 index 00000000..45b4b5e9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/team.ts @@ -0,0 +1,157 @@ +import type { ApiClient } from "../client"; +import { + ApiRequestError, + type PermissionArea, + type PermissionKey, + type Role, + type User, +} from "../types"; +import { get, post, request } from "./request"; + +interface SearchyResponse { + result: T[]; + totalCount: number; +} + +interface RawRoleSummary { + id: number; + name: string; +} + +interface RawAccount { + id: number; + name: string; + email: string; + role: string; + disabled: boolean; + createdOn: string; + roles: RawRoleSummary[] | null; +} + +interface RawRole { + id: number; + name: string; + description: string | null; + isSystem: boolean; + permissions: string[] | null; + memberCount: number; + createdOn: string; +} + +const toUser = (r: RawAccount): User => ({ + id: String(r.id), + displayName: r.name, + email: r.email, + roleIds: (r.roles ?? []).map((role) => String(role.id)), + status: r.disabled ? "disabled" : "active", + // Not tracked by the backend: no login-method projection, no last-seen column. + microsoftLinked: false, + createdOn: r.createdOn, +}); + +const toRole = (r: RawRole): Role => ({ + id: String(r.id), + name: r.name, + description: r.description ?? "", + permissions: r.permissions ?? [], + isSystem: r.isSystem, + createdOn: r.createdOn, + memberCount: r.memberCount, +}); + +/** One page big enough for any realistic team — the backend defaults to 20. */ +const ALL_MEMBERS = 500; + +async function fetchUsers(): Promise { + const res = await get>(`/accounts?limit=${ALL_MEMBERS}`); + return (res.result ?? []).map(toUser); +} + +async function fetchUser(id: string): Promise { + // No GET /accounts/{id} exists, so read the member out of the list. + const user = (await fetchUsers()).find((u) => u.id === id); + if (!user) throw new ApiRequestError("NOT_FOUND", "This member no longer exists."); + return user; +} + +export const teamMethods = { + // — permission catalog — + async getPermissionCatalog(): Promise { + return await get("/permissions"); + }, + + // — members — + listUsers: fetchUsers, + getUser: fetchUser, + + async createUser({ + displayName, + email, + password, + roleIds, + }: { + displayName: string; + email: string; + password: string; + roleIds: string[]; + }): Promise { + const id = await post("/accounts", { + name: displayName, + email, + password, + roleIds: roleIds.map(Number), + }); + return fetchUser(String(id)); + }, + + async setUserPassword(id: string, password: string): Promise { + await post(`/accounts/${id}/setPassword`, { password }); + }, + + async updateUserRoles(id: string, roleIds: string[]): Promise { + await post(`/accounts/${id}/setRoles`, { roleIds: roleIds.map(Number) }); + return fetchUser(id); + }, + + async setUserDisabled(id: string, disabled: boolean): Promise { + await post(`/accounts/${id}/setDisabled`, { disabled }); + return fetchUser(id); + }, + + async deleteUser(id: string): Promise { + await post(`/accounts/${id}/remove`, {}); + }, + + // — roles — + async listRoles(): Promise { + const res = await get>("/roles?pageSize=200"); + return (res.result ?? []).map(toRole); + }, + + async getRole(id: string): Promise { + const raw = await get(`/roles/${id}`); + if (!raw) throw new ApiRequestError("NOT_FOUND", "This role no longer exists."); + return toRole(raw); + }, + + async createRole(input: { + name: string; + description: string; + permissions: PermissionKey[]; + }): Promise { + const id = await post("/roles", input); + return teamMethods.getRole(String(id)); + }, + + async updateRole( + id: string, + input: { name: string; description: string; permissions: PermissionKey[] }, + ): Promise { + await post(`/roles/${id}`, input); + return teamMethods.getRole(id); + }, + + async deleteRole(id: string): Promise { + await request(`/roles/${id}`, { method: "DELETE" }); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts new file mode 100644 index 00000000..76749ae4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts @@ -0,0 +1,149 @@ +import type { ApiClient } from "../client"; +import { + ApiRequestError, + type IntegrationType, + type WorkGroup, + type WorkGroupDetail, + type WorkGroupRow, +} from "../types"; +import { get, getEnrichment, post } from "./request"; + +interface SearchyResponse { + result: T[]; + totalCount: number; +} +interface RawWorkGroup { + id: number; + name: string; + busMessageName: string; + options: { rabbitMqOptions: { prefetch: number | null; priority: number | null } | null } | null; + processorNodeCount: number | null; +} +interface RawSubscriptionRef { + id: number; + name: string; + type: number | string; +} + +const SUB_TYPE_BY_NUM: Record = { + 1: "Internal", + 2: "ApiCall", + 4: "Receiving", + 8: "Aggregation", + 16: "GatewayApiCall", + 32: "BusGateway", +}; +const INTEGRATION_TYPES: IntegrationType[] = [ + "Receiving", + "GatewayApiCall", + "BusGateway", + "Internal", + "ApiCall", + "Aggregation", +]; +/** Enums may arrive as the numeric value or the name in any case. */ +const toIntegrationType = (t: number | string): IntegrationType => { + if (typeof t === "number") return SUB_TYPE_BY_NUM[t] ?? "Internal"; + return INTEGRATION_TYPES.find((k) => k.toLowerCase() === t.toLowerCase()) ?? "Internal"; +}; + +async function fetchSubscriptionsByWorkGroup(workGroupId: number): Promise { + const res = await get>( + `/subscriptions?filter=${encodeURIComponent(`WorkGroupId:1:${workGroupId}`)}`, + ); + return res.result ?? []; +} + +// WorkGroup has no CreatedOn column on the backend. +const toWorkGroup = (w: RawWorkGroup): WorkGroup => ({ + id: w.id, + name: w.name, + busMessageName: w.busMessageName, + options: { + rabbitMqOptions: { + consumerSettings: { + prefetch: w.options?.rabbitMqOptions?.prefetch ?? 0, + priority: w.options?.rabbitMqOptions?.priority ?? 0, + }, + }, + }, + createdOn: "", +}); + +async function fetchRows(): Promise { + const res = await get>("/workgroups"); + return res.result ?? []; +} + +export const workGroupMethods = { + async listWorkGroups(): Promise { + const [rows, subs] = await Promise.all([ + fetchRows(), + getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), + ]); + const countByWorkGroup = new Map(); + for (const s of subs.result ?? []) { + if (s.workGroupId == null) continue; + countByWorkGroup.set(s.workGroupId, (countByWorkGroup.get(s.workGroupId) ?? 0) + 1); + } + return rows.map((w) => ({ + ...toWorkGroup(w), + usedByCount: countByWorkGroup.get(w.id) ?? 0, + consumerCount: w.processorNodeCount ?? 0, + })); + }, + + async getWorkGroup(id: number): Promise { + const [rows, subs] = await Promise.all([fetchRows(), fetchSubscriptionsByWorkGroup(id)]); + const w = rows.find((x) => x.id === id); + if (!w) throw new ApiRequestError("NOT_FOUND", "This work group no longer exists."); + return { + ...toWorkGroup(w), + integrations: subs.map((s) => ({ id: s.id, name: s.name, type: toIntegrationType(s.type) })), + }; + }, + + async createWorkGroup(input: { + name: string; + busMessageName: string; + prefetch: number; + priority: number; + }): Promise { + const created = await post<{ id: number }>("/workgroups", { + name: input.name, + busMessageName: input.busMessageName, + options: { rabbitMqOptions: { prefetch: input.prefetch, priority: input.priority } }, + }); + return { + id: created.id, + name: input.name, + busMessageName: input.busMessageName, + options: { rabbitMqOptions: { consumerSettings: { prefetch: input.prefetch, priority: input.priority } } }, + createdOn: "", + }; + }, + + async updateWorkGroup( + id: number, + changes: { name: string; busMessageName: string; prefetch: number; priority: number }, + ): Promise { + await post(`/workgroups/${id}`, { + name: changes.name, + busMessageName: changes.busMessageName, + options: { rabbitMqOptions: { prefetch: changes.prefetch, priority: changes.priority } }, + }); + return { + id, + name: changes.name, + busMessageName: changes.busMessageName, + options: { + rabbitMqOptions: { consumerSettings: { prefetch: changes.prefetch, priority: changes.priority } }, + }, + createdOn: "", + }; + }, + + async deleteWorkGroup(id: number): Promise { + await post(`/workgroups/${id}/delete`, {}); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/index.ts b/SW.Bitween.Web/ClientApp/src/api/index.ts new file mode 100644 index 00000000..14fd7935 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/index.ts @@ -0,0 +1,14 @@ +import type { ApiClient } from "./client"; +import { httpClient } from "./http/httpClient"; + +/** + * The swap point. Everything in the UI imports `api` from here. + * This is the real HTTP client — no mock, no toggle. Domains not yet wired + * reject with NotWiredError so their screens read as honestly-not-connected + * (see BACKEND_WIRING_PLAN §2). + */ +export const api: ApiClient = httpClient; + +export { getAppConfig, resetAppConfig } from "./http/appConfig"; +export type { AppConfig } from "./http/appConfig"; +export * from "./types"; diff --git a/SW.Bitween.Web/ClientApp/src/api/permissions.ts b/SW.Bitween.Web/ClientApp/src/api/permissions.ts new file mode 100644 index 00000000..a1cb5923 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/permissions.ts @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "."; +import type { ActionId, PermissionArea, PermissionKey } from "./types"; + +/** + * The permission catalog is defined and enforced in the backend (SW.Bitween.Sdk/Model/Permissions.cs) + * and served by GET /permissions. It deliberately does not live here as well: a second copy would + * silently drift, and the role matrix would start offering grants the API ignores. + * + * What stays here is presentation — how an action is worded and the order columns appear in. + */ +export const ACTION_LABELS: Record = { + view: "View", + create: "Create", + edit: "Edit", + delete: "Delete", + operate: "Operate", +}; + +/** All actions in the order matrix columns render. */ +export const ACTION_ORDER: ActionId[] = ["view", "create", "edit", "delete", "operate"]; + +export const permissionKey = (areaId: string, actionId: ActionId): PermissionKey => + `${areaId}.${actionId}`; + +/** Static per deployment, so it's fetched once and kept. */ +export function usePermissionCatalog() { + return useQuery({ + queryKey: ["permission-catalog"], + queryFn: () => api.getPermissionCatalog(), + staleTime: Infinity, + }); +} + +export const allKeysIn = (areas: PermissionArea[]): PermissionKey[] => + areas.flatMap((area) => area.actions.map((a) => permissionKey(area.id, a.id as ActionId))); + +/** Navigation groups, in the order the backend lists them. */ +export const groupsIn = (areas: PermissionArea[]): string[] => [ + ...new Set(areas.map((area) => area.group)), +]; + +/** "Integrations · Edit" for a key, falling back to the raw key for anything unrecognised. */ +export const labelIn = (areas: PermissionArea[], key: PermissionKey): string => { + const [areaId, actionId] = key.split("."); + const area = areas.find((a) => a.id === areaId); + return area ? `${area.label} · ${ACTION_LABELS[actionId as ActionId] ?? actionId}` : key; +}; diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts new file mode 100644 index 00000000..1f92efd1 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -0,0 +1,761 @@ +/** Shared API shapes. These model the contract the real backend will need to satisfy. */ + +export type ActionId = "view" | "create" | "edit" | "delete" | "operate"; + +/** e.g. "subscriptions.edit" */ +export type PermissionKey = string; + +export interface PermissionAction { + id: ActionId; + /** What this specific grant allows, in end-user words. */ + description: string; +} + +export interface PermissionArea { + id: string; + label: string; + group: string; + description: string; + actions: PermissionAction[]; +} + +export interface Role { + id: string; + name: string; + description: string; + permissions: PermissionKey[]; + /** Built-in roles (Administrator) can't be edited or deleted. */ + isSystem: boolean; + createdOn: string; + memberCount: number; +} + +export type UserStatus = "active" | "disabled"; + +export interface User { + id: string; + displayName: string; + email: string; + roleIds: string[]; + status: UserStatus; + /** Whether a Microsoft account is linked for SSO. */ + microsoftLinked: boolean; + createdOn: string; + lastActiveOn?: string; +} + +/** Just enough to name a role. Full definitions need the roles.view permission. */ +export interface RoleSummary { + id: string; + name: string; +} + +export interface Session { + user: User; + roles: RoleSummary[]; + /** The union of every permission the user's roles grant — resolved by the backend. */ + permissions: PermissionKey[]; +} + +export interface ApiError { + code: string; + message: string; +} + +export class ApiRequestError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +/** + * Thrown by any ApiClient method whose domain hasn't been wired to the real + * backend yet. Screens surface this as an honest "Not connected yet" state — + * never fake data. Batches remove these as they land. + */ +export class NotWiredError extends Error { + code = "NOT_WIRED"; + constructor(method: string) { + super(`"${method}" isn't connected to the backend yet.`); + } +} + +// ——— Configuration entities (sub-phase 2) ——— + +/** Lightweight references for "used by" panels. */ +export interface IntegrationSetupRef { + id: number; + name: string; + type: IntegrationType; +} +export interface ApiGatewayAttachmentRef { + gatewayId: number; + gatewayName: string; + urlName: string; +} +export interface BusGatewayRouteRef { + gatewayId: number; + gatewayName: string; + matchExpression: string; +} +/** The pipeline stages that can each produce a document for an exchange. */ +export type ExchangeDocStage = "Input" | "Mapped" | "Handled"; +export interface ExchangeDocument { + stage: ExchangeDocStage; + content: string; +} +export interface ExchangeRef { + id: string; + partnerName?: string; + informationTypeCode: string; + status: ExchangeStatus; + on: string; + /** Documents produced as the exchange moved through the pipeline, for drill-down previews. */ + documents?: ExchangeDocument[]; +} + +/** + * Lightweight summary of every integration, cached client-side so pages + * can answer "who uses this property/value/policy?" without new requests. + * Derived server-side by scanning adapter property values for tokens. + */ +export interface IntegrationInfo { + id: number; + name: string; + type: IntegrationType; + /** + * Partners this integration runs for: its own partner (legacy types) + * plus partners linked through gateway attachments and bus routes. + */ + partnerIds: number[]; + informationTypeId: number; + workGroupId: number | null; + retryPolicyId: number | null; + /** Keys of partner properties referenced by its adapters ({{partner.KEY}}). */ + partnerPropKeys: string[]; + /** Global value references, per set. */ + globals: { setId: string; keys: string[] }[]; +} +export interface TrailEntry { + on: string; + action: "Created" | "Updated"; + by: string; + /** Absent for system-attributed entries with no real team member behind them. */ + byUserId?: string; +} + +export interface ApiCredentialRef { + name: string; + /** Only the first characters — full keys are shown once, at creation. */ + keyPrefix: string; + createdOn: string; +} + +export interface Partner { + id: number; + name: string; + /** Referenced in adapter configs as {{partner.KEY}}. */ + adapterProperties: Record; + /** The built-in SYSTEM partner can't be renamed or deleted. */ + isSystem: boolean; + createdOn: string; +} +export interface PartnerRow extends Partner { + credentialCount: number; + usedByCount: number; + /** Property names only — the list endpoint never sends values, which may be secrets. */ + propertyKeys: string[]; +} +export interface PartnerDetail extends Partner { + apiCredentials: ApiCredentialRef[]; + integrationSetups: IntegrationSetupRef[]; + apiGateways: ApiGatewayAttachmentRef[]; + busGatewayRoutes: BusGatewayRouteRef[]; + recentExchanges: ExchangeRef[]; +} + +export type InformationTypeFormat = "Json" | "Xml"; + +export interface InformationType { + id: number; + /** Short unique identity shown across the system, e.g. PURCHASE_ORDER. Optional. */ + code?: string; + name: string; + format: InformationTypeFormat; + busEnabled: boolean; + busMessageTypeName?: string; + /** How long an identical incoming payload counts as a duplicate. 0 = off. */ + duplicateIntervalMinutes: number; + disregardsUnfilteredMessages: boolean; + /** Friendly name → JSONPath/XPath, matched by routes and filters. */ + promotedProperties: { key: string; path: string }[]; + createdOn: string; +} +export interface InformationTypeRow extends InformationType { + usedByCount: number; +} +export interface InformationTypeDetail extends InformationType { + integrationSetups: IntegrationSetupRef[]; + busGateways: { gatewayId: number; gatewayName: string }[]; + trail: TrailEntry[]; + recentExchanges: ExchangeRef[]; +} + +export interface GlobalValuesSet { + /** Caller-chosen slug; referenced as {{globals..}}. */ + id: string; + name: string; + values: Record; + createdOn: string; +} +export interface GlobalValuesSetRow extends GlobalValuesSet { + usedByCount: number; +} +export interface ValueSetUsage { + integrationSetup: IntegrationSetupRef; + keys: string[]; +} +export interface GlobalValuesSetDetail extends GlobalValuesSet { + usedBy: ValueSetUsage[]; +} + +// ——— Retry policies ——— + +export type RetryResultType = "Error" | "BadResult"; + +export type RetryMatcher = + | { type: "contains"; value: string; caseSensitive: boolean } + | { type: "regex"; pattern: string; flags: string } + | { type: "exceptionType"; value: string; includeInner: boolean } + | { type: "jsonPath"; path: string; op: "Eq" | "Neq" | "Contains" | "Exists" | "NotExists"; value?: string }; + +export type RetryDelay = + | { type: "fixed"; delaySeconds: number } + | { type: "linear"; initialSeconds: number; incrementSeconds: number } + | { type: "exponential"; initialSeconds: number; multiplier: number; maxSeconds: number }; + +export interface RetryGroup { + id: string; + name: string; + /** Lower runs first; the first matching group decides. */ + priority: number; + enabled: boolean; + appliesTo: RetryResultType[]; + /** OR logic; empty = any failure of the applicable kind. */ + matchers: RetryMatcher[]; + action: "Allow" | "Block"; + budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number; delay: RetryDelay }; + notes?: string; +} + +export interface RetryPolicy { + id: number; + name: string; + groups: RetryGroup[]; + createdOn: string; +} +export interface RetryPolicyListRow { + id: number; + name: string; + /** The list only counts groups; the full list is in the detail response. */ + groupCount: number; + createdOn: string; + usedByCount: number; +} +export interface RetryPolicyDetail extends RetryPolicy { + integrations: IntegrationSetupRef[]; +} + +export interface RetryTestAttempt { + attempt: number; + shouldRetry: boolean; + delaySeconds?: number; + matchedGroup?: string; + reason: string; +} + +// ——— Notifiers ——— + +export interface Notifier { + id: number; + name: string; + /** Off pauses the notifier without losing its setup. */ + enabled: boolean; + /** Which exchange outcomes trigger a notification. */ + onFailed: boolean; + onBadResult: boolean; + onSuccess: boolean; + channelId: string; + channelProperties: Record; + /** Integrations this notifier watches; empty = it never fires. */ + integrationIds: number[]; + createdOn: string; +} + +/** One delivery attempt from the notification history. */ +export interface NotificationEntry { + xchangeId: string; + success: boolean; + exception?: string; + on: string; +} + +export interface NotifierDetail extends Notifier { + recentNotifications: NotificationEntry[]; +} + +// ——— Integrations (subscriptions) ——— + +/** + * Backend Subscription.Type. Aggregation exists in data but is deferred in + * this UI; Internal and ApiCall are legacy — shown and editable, never created. + */ +export type IntegrationType = + | "Receiving" + | "GatewayApiCall" + | "BusGateway" + | "Internal" + | "ApiCall" + | "Aggregation"; + +export type AdapterKind = "receiver" | "handler" | "mapper" | "validator"; + +/** One configurable property of an adapter, from its startup-value metadata. */ +export interface AdapterProp { + key: string; + optional: boolean; + default?: string; + /** Secret values are write-only: masked after save, replaced not edited. */ + secret: boolean; + description?: string; +} + +export interface AdapterInfo { + id: string; + kind: AdapterKind; + /** Friendly display name, e.g. "HTTP endpoint" for NativeHttpHandler. */ + label: string; + /** Native adapters run in-process; others are deployed packages with versions. */ + native: boolean; + versions: string[]; + props: AdapterProp[]; +} + +/** + * Message filter over an information type's promoted properties. + * Groups are n-ary here (friendlier to edit); the backend's binary + * and/or tree converts losslessly both ways. + */ +export type MatchCondition = { + op: "oneOf" | "notOneOf"; + path: string; + values: string[]; +}; +export type MatchGroup = { + op: "and" | "or"; + children: MatchNode[]; +}; +export type MatchNode = MatchGroup | MatchCondition; + +export type Recurrence = "Hourly" | "Daily" | "Weekly" | "Monthly"; + +export interface Schedule { + recurrence: Recurrence; + /** Weekly: weekday 0–6 (Sun–Sat); Monthly: day of month 1–27; otherwise 0. */ + days: number; + hours: number; + minutes: number; + /** Count the offset from the end of the period instead of the start. */ + backwards: boolean; +} + +export interface Integration { + id: number; + name: string; + type: IntegrationType; + informationTypeId: number; + /** Direct partner — legacy Internal/ApiCall (and Aggregation) only. */ + partnerId: number | null; + /** Inverse of backend Inactive. Disabled = not scheduled, not matched. */ + enabled: boolean; + /** Paused still accepts work but holds it for later release. */ + pausedOn: string | null; + workGroupId: number | null; + retryPolicyId: number | null; + receiverId: string | null; + receiverProperties: Record; + validatorId: string | null; + validatorProperties: Record; + mapperId: string | null; + mapperProperties: Record; + handlerId: string | null; + handlerProperties: Record; + /** Legacy Internal only: which documents this integration picks up. */ + matchExpression: MatchGroup | null; + /** Receiving (and Aggregation) only. */ + schedules: Schedule[]; + /** Feed the handler's response into another integration. */ + responseIntegrationId: number | null; + responseMessageTypeName: string | null; + aggregationForId: number | null; + // — health (read-only) — + isRunning: boolean; + /** Receiving (and Aggregation) only — when the schedule will next fire, not when it last did. */ + nextReceiveOn: string | null; + consecutiveFailures: number; + lastException: string | null; + createdOn: string; +} + +export interface IntegrationRow { + id: number; + name: string; + type: IntegrationType; + informationTypeId: number; + informationTypeCode: string; + partners: { id: number; name: string }[]; + enabled: boolean; + paused: boolean; + isRunning: boolean; + consecutiveFailures: number; + lastException: string | null; + scheduleSummary?: string; + /** Receiving (and Aggregation) only — when the schedule will next fire, not when it last did. */ + nextReceiveOn: string | null; + createdOn: string; +} + +export interface IntegrationDetail extends Integration { + informationTypeCode: string; + informationTypeName: string; + /** Where this integration is plugged in (entry points). */ + apiGatewayAttachments: { gatewayId: number; gatewayName: string; urlName: string; partnerId: number; partnerName: string }[]; + busGatewayRoutes: { gatewayId: number; gatewayName: string; partnerId: number | null; partnerName: string | null }[]; + watchingNotifiers: { id: number; name: string }[]; + recentExchanges: ExchangeRef[]; + trail: TrailEntry[]; +} + +export interface WorkGroupOptions { + rabbitMqOptions: { + consumerSettings: { + prefetch: number; + priority: number; + }; + }; +} + +export interface WorkGroup { + id: number; + name: string; + /** Queue name suffix; combined with the id to form the real queue name. */ + busMessageName: string; + options: WorkGroupOptions; + createdOn: string; +} +export interface WorkGroupRow extends WorkGroup { + usedByCount: number; + /** Best-effort live count from the RabbitMQ management API. */ + consumerCount: number; +} +export interface WorkGroupDetail extends WorkGroup { + integrations: IntegrationSetupRef[]; +} + +// ——— API gateways ——— + +export interface ApiGatewayAttachment { + partnerId: number; + partnerName: string; + integrationId: number; + integrationName: string; +} +export interface ApiGateway { + id: number; + name: string; + urlName: string; + createdOn: string; +} +export interface ApiGatewayRow extends ApiGateway { + partnerCount: number; + attachments: ApiGatewayAttachment[]; +} +export interface ApiGatewayDetail extends ApiGateway { + attachments: ApiGatewayAttachment[]; +} + +// ——— Bus gateways ——— + +export interface BusGatewayRoute { + id: number; + integrationId: number; + integrationName: string; + partnerId: number | null; + partnerName: string | null; + /** null = route matches every message of the gateway's type. */ + matchExpression: MatchGroup | null; +} +export interface BusGateway { + id: number; + name: string; + informationTypeId: number; + createdOn: string; +} +export interface BusGatewayRow extends BusGateway { + informationTypeCode: string; + routeCount: number; + routes: BusGatewayRoute[]; +} +export interface BusGatewayDetail extends BusGateway { + informationTypeCode: string; + informationTypeName: string; + routes: BusGatewayRoute[]; +} + +// ——— Settings ——— + +export type SettingValueKind = "string" | "number" | "boolean" | "color"; + +/** + * How a row behaves: + * - `editable` — stored in the database, changeable here, takes effect immediately. + * - `readonly` — an environment value, shown so you can see what this instance runs + * on. Read once at startup, so there's nothing to change here. + * - `presence` — an environment value whose content stays on the server; only + * whether it's set is reported. + */ +export type SettingAccess = "editable" | "readonly" | "presence"; + +/** + * One setting on the settings page. Only settings that take effect immediately are + * editable, so there is no "restart required" state to represent — anything read + * once at startup appears as `readonly` or `presence` instead. + */ +export interface SettingRow { + /** Stable key; mirrors the config path (e.g. "Bitween.RebexLicenseKey"). */ + key: string; + /** Grouping label owned by the backend catalog; the page derives its pills from these. */ + section: string; + label: string; + description: string; + kind: SettingValueKind; + /** The product default a reset returns to. Stored as a string per `kind`; empty for secrets. */ + defaultValue: string; + /** The stored value, or null for a secret — those never leave the server. */ + value: string | null; + secret: boolean; + /** The stored value differs from the product default, so a reset would do something. */ + overridden: boolean; + /** Whether the effective value is non-empty — the only way a secret reveals it is set. */ + hasValue: boolean; + /** + * Whether this row can be written. False for every environment value, and for a secret on an + * instance with no encryption key configured — there's nowhere safe to store that one. + */ + editable: boolean; + access: SettingAccess; +} + +// ——— Exchanges ——— + +/** + * The four observable outcomes of an exchange. "badResponse" = the handler + * delivered but the receiving system answered with a business-level error. + */ +export type ExchangeStatus = "processing" | "success" | "badResponse" | "failed"; + +export interface ExchangeFileRef { + name: string; + /** Bytes. */ + size: number; + /** Storage key to fetch this stage's content through `getExchangeDocument`; null if no file exists. */ + key: string | null; +} + +/** One exchange as the Exchanges page sees it — names pre-resolved for display. */ +export interface ExchangeRow { + id: string; + status: ExchangeStatus; + integrationId: number | null; + integrationName: string | null; + informationTypeId: number; + informationTypeCode: string; + partnerId: number | null; + partnerName: string | null; + startedOn: string; + /** null while still processing. */ + finishedOn: string | null; + correlationId: string | null; + /** Set when this exchange is a retry of another one. */ + retryFor: string | null; + /** Set when this exchange was rolled up into an aggregation exchange. */ + aggregationXchangeId: string | null; + /** A pending auto-retry, when the retry policy scheduled one. */ + scheduledRetryOn: string | null; + exception: string | null; + promotedProperties: Record | null; + /** True when the integration has no mapper — the Mapped stage is skipped. */ + mapperSkipped: boolean; + files: { + input: ExchangeFileRef | null; + mapped: ExchangeFileRef | null; + handled: ExchangeFileRef | null; + }; +} + +export interface ExchangeQuery { + status?: ExchangeStatus; + integrationId?: number; + partnerId?: number; + informationTypeId?: number; + /** Comma/pipe/newline separated; matches id, retryFor OR aggregationXchangeId. */ + ids?: string; + correlationId?: string; + /** Substring match against promoted property keys and values. */ + property?: string; + from?: string; + to?: string; + offset: number; + limit: number; +} + +export interface Paged { + result: T[]; + total: number; +} + +// ——— Scheduled retries ——— + +/** A failed exchange whose retry policy scheduled an automatic retry. */ +export interface ScheduledRetryRow { + /** The exchange the retry will re-run. */ + id: string; + /** When the retry job will pick it up. */ + on: string; + integrationId: number | null; + integrationName: string | null; + informationTypeId: number; + informationTypeCode: string; + exception: string | null; + /** When the failed exchange originally started. */ + startedOn: string; + /** + * The shared retry policy the integration currently points at. Null when the + * policy is defined inline on the integration instead, so the integration — not + * the Retry policies list — is where to go and look. + */ + retryPolicyId: number | null; + retryPolicyName: string | null; +} + +export interface ScheduledRetryQuery { + integrationId?: number; + informationTypeId?: number; + /** Substring match against the exception text. */ + exception?: string; + from?: string; + to?: string; + offset: number; + limit: number; +} + +// ——— Queue health (Ops) ——— + +export type QueueSeverity = "healthy" | "warning" | "critical"; + +export interface QueueHealthSummary { + totalConsumers: number; + unhealthyConsumers: number; + disconnectedConsumers: number; + totalQueueDepth: number; + totalRetryBacklog: number; + totalDeadLetterBacklog: number; + /** Messages per second, across all queues. */ + totalIncomingRate: number; + totalAckRate: number; + lastUpdated: string; +} + +export interface ConsumerHealth { + name: string; + messageName: string; + queueName: string; + /** Set when the consumer belongs to a work group, for drill-down. */ + workGroupId: number | null; + totalNodes: number; + processingCount: number; + queueCount: number; + retryCount: number; + failedCount: number; + priority: number; + prefetch: number; + incomingRate: number; + ackRate: number; + isBackpressured: boolean; + health: QueueSeverity; +} + +export interface RetryBacklogRow { + consumerName: string; + queueName: string; + retryBacklog: number; + incomingRate: number; + ackRate: number; + severity: QueueSeverity; +} + +export interface DeadLetterRow { + consumerName: string; + queueName: string; + count: number; + lastExceptionType: string | null; + lastExceptionMessage: string | null; + lastFailedAt: string | null; +} + +export interface QueueAlert { + severity: "warning" | "critical"; + title: string; + detail: string; + queueName: string; + on: string; +} + +/** One poll = one snapshot; everything the Queue health page shows. */ +export interface QueueHealthSnapshot { + summary: QueueHealthSummary; + consumers: ConsumerHealth[]; + retryBacklog: RetryBacklogRow[]; + deadLetters: DeadLetterRow[]; + alerts: QueueAlert[]; +} + +// ——— Dashboard ——— + +export interface DashboardData { + today: { total: number; failed: number; processing: number }; + yesterdayTotal: number; + /** Percentage 0–100 across the last 7 days of finished exchanges. */ + successRate7d: number; + pendingRetries: number; + queueAlerts: number; + /** Last 14 days, oldest first; today is the final entry. */ + trafficByDay: { date: string; success: number; failed: number }[]; + /** Top integrations by 7-day traffic, busiest first. */ + busiest: { id: number; name: string; count: number; failed: number }[]; + latestFailures: { + id: string; + status: ExchangeStatus; + integrationId: number | null; + integrationName: string | null; + informationTypeCode: string; + on: string; + exception: string | null; + }[]; + attention: { + failingIntegrations: { id: number; name: string; consecutiveFailures: number }[]; + pausedIntegrations: { id: number; name: string }[]; + }; +} diff --git a/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx new file mode 100644 index 00000000..7c520dd7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { api, type PermissionKey, type Session } from "../api"; + +interface SessionContextValue { + session: Session | null; + /** True until the stored session has been checked once at startup. */ + initializing: boolean; + can: (permission: PermissionKey) => boolean; + signIn: (email: string, password: string) => Promise; + signInWithMicrosoft: () => Promise; + /** Adopt a session produced elsewhere (invite acceptance, demo switch). */ + adoptSession: (session: Session) => void; + /** Re-fetch the session after profile changes. */ + refresh: () => Promise; + signOut: () => Promise; +} + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [initializing, setInitializing] = useState(true); + const queryClient = useQueryClient(); + + useEffect(() => { + api + .getSession() + .then(setSession) + .finally(() => setInitializing(false)); + }, []); + + const adoptSession = useCallback( + (next: Session) => { + // a different identity invalidates everything previously fetched + queryClient.clear(); + setSession(next); + }, + [queryClient], + ); + + const signIn = useCallback( + async (email: string, password: string) => { + const next = await api.login(email, password); + adoptSession(next); + return next; + }, + [adoptSession], + ); + + const signInWithMicrosoft = useCallback(async () => { + const next = await api.loginWithMicrosoft(); + adoptSession(next); + return next; + }, [adoptSession]); + + const refresh = useCallback(async () => { + setSession(await api.getSession()); + }, []); + + const signOut = useCallback(async () => { + await api.logout(); + queryClient.clear(); + setSession(null); + }, [queryClient]); + + const value = useMemo( + () => ({ + session, + initializing, + can: (permission) => session?.permissions.includes(permission) ?? false, + signIn, + signInWithMicrosoft, + adoptSession, + refresh, + signOut, + }), + [session, initializing, signIn, signInWithMicrosoft, adoptSession, refresh, signOut], + ); + + return {children}; +} + +export function useSession(): SessionContextValue { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSession must be used inside "); + return ctx; +} diff --git a/SW.Bitween.Web/ClientApp/src/auth/guards.tsx b/SW.Bitween.Web/ClientApp/src/auth/guards.tsx new file mode 100644 index 00000000..d87db0e2 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/guards.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { Navigate, Outlet, useLocation } from "react-router"; +import { Lock } from "lucide-react"; +import type { PermissionKey } from "../api"; +import { labelIn, usePermissionCatalog } from "../api/permissions"; +import { useSession } from "./SessionContext"; + +/** Redirects to /login when signed out; shows a splash while checking. */ +export function RequireAuth() { + const { session, initializing } = useSession(); + const location = useLocation(); + + if (initializing) { + return ( +
+ Bitween +
+ ); + } + if (!session) { + return ; + } + return ; +} + +/** Full-page stop for routes the session's roles don't unlock. */ +export function AccessDenied({ permission }: { permission: PermissionKey }) { + // Falls back to the raw key until the catalog lands — never blocks the message. + const label = labelIn(usePermissionCatalog().data ?? [], permission); + return ( +
+ + + +

You don't have access to this page

+

+ Seeing it needs the {label}{" "} + permission, which none of your roles grant. An administrator can change that under Team → Roles. +

+
+ ); +} + +/** Route-level permission gate. */ +export function RequirePermission({ + permission, + children, +}: { + permission: PermissionKey; + children: ReactNode; +}) { + const { can } = useSession(); + if (!can(permission)) return ; + return <>{children}; +} + +/** Inline gate: unauthorized actions are hidden, never dimmed. */ +export function Can({ permission, children }: { permission: PermissionKey; children: ReactNode }) { + const { can } = useSession(); + if (!can(permission)) return null; + return <>{children}; +} + +/** Convenience hook for components that branch on a permission. */ +export function useSessionCan(permission: PermissionKey): boolean { + const { can } = useSession(); + return can(permission); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx new file mode 100644 index 00000000..37fa3299 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -0,0 +1,513 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowUpRight, Braces, ChevronDown, ChevronRight, Search } from "lucide-react"; +import { api, type AdapterInfo, type AdapterKind, type PartnerRow } from "../../api"; +import { Button } from "../ui/basics"; +import { Field } from "../ui/forms"; +import { SearchSelect } from "../ui/SearchSelect"; +import { SummaryDisclosure } from "../ui/SummaryDisclosure"; + +export function useAdapterCatalog(kind: AdapterKind) { + return useQuery({ + queryKey: ["adapters", kind], + queryFn: () => api.listAdapters(kind), + staleTime: Infinity, + }); +} + +interface ReferenceToken { + label: string; + token: string; + /** Globals resolve to one literal value; partner properties resolve per-exchange. */ + value?: string; +} + +/** All insertable reference tokens: every global key + every known partner property. */ +function useReferenceTokens() { + const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets(), staleTime: 60_000 }); + const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners(), staleTime: 60_000 }); + const globals: ReferenceToken[] = (sets.data ?? []).flatMap((s) => + Object.entries(s.values).map(([key, value]) => ({ + label: `${s.id}.${key}`, + token: `{{globals.${s.id}.${key}}}`, + value, + })), + ); + // propertyKeys, not adapterProperties: the list endpoint deliberately withholds + // property values (they can be secrets) and sends only their names, which is all + // a reference token needs. + const partnerKeys: ReferenceToken[] = [ + ...new Set((partners.data ?? []).flatMap((p) => p.propertyKeys)), + ].map((key) => ({ label: `partner.${key}`, token: `{{partner.${key}}}` })); + return { globals, partnerKeys, partners: partners.data ?? [] }; +} + +/** + * One partner's value for a `{{partner.KEY}}` reference. + * + * Fetched per partner, and only once the row is expanded. The partners list + * deliberately carries property names without their values, so that something like + * an FTP password isn't sitting in the browser on every adapter screen; this pulls + * the value on demand, for the handful of partners actually being inspected. + */ +function PartnerPropValue({ partnerId, propKey }: { partnerId: number; propKey: string }) { + const props = useQuery({ + queryKey: ["partner-adapter-properties", partnerId], + queryFn: () => api.getPartnerAdapterProperties(partnerId), + staleTime: 60_000, + }); + if (props.isPending) return loading…; + const value = props.data?.[propKey]; + return ( + + {value === undefined || value === "" ? "—" : value} + + ); +} + +/** + * Searchable popover for inserting a `{{globals.…}}` / `{{partner.…}}` + * reference. Globals show their literal value; partner properties resolve + * per-exchange, so they show a note instead. + */ +function ReferenceMenu({ + globals, + partnerKeys, + onPick, + label, +}: { + globals: ReferenceToken[]; + partnerKeys: ReferenceToken[]; + onPick: (token: string) => void; + label: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const needle = query.trim().toLowerCase(); + const matches = (t: ReferenceToken) => + !needle || t.label.toLowerCase().includes(needle) || (t.value?.toLowerCase().includes(needle) ?? false); + const filteredGlobals = globals.filter(matches); + const filteredPartnerKeys = partnerKeys.filter(matches); + const noMatches = filteredGlobals.length === 0 && filteredPartnerKeys.length === 0; + + const pick = (token: string) => { + onPick(token); + setOpen(false); + setQuery(""); + }; + + return ( +
+ + {open && ( +
+
+ + setQuery(e.target.value)} + placeholder="Search references" + aria-label="Search references" + className="h-8 w-full rounded-md border border-ink-200 bg-white pr-2 pl-8 text-[13px] placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+ {noMatches &&

No matches.

} + {filteredGlobals.length > 0 && ( +
+

+ Global values +

+ {filteredGlobals.map((t) => ( + + ))} +
+ )} + {filteredPartnerKeys.length > 0 && ( +
+

+ Partner properties +

+ {filteredPartnerKeys.map((t) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} + +/** + * What the references inside a field's value resolve to, shown right under + * it: globals show their literal value (linking to the set); partner + * properties show how many partners define them and drill down to every + * partner's value. + */ +function ReferenceHints({ + value, + globals, + partners, +}: { + value: string; + globals: ReferenceToken[]; + partners: PartnerRow[]; +}) { + const [openKeys, setOpenKeys] = useState>(new Set()); + + + // Set ids and keys are free-form (spaces included) on the backend — match on the + // `{{…}}` delimiters themselves, not a restrictive charset, or refs with a space + // in the id/key (e.g. "schedule source url") silently fail to match here. + const globalRefs = [...new Set([...value.matchAll(/\{\{globals\.[^}]+\}\}/g)].map((m) => m[0]))]; + const partnerRefs = [...new Set([...value.matchAll(/\{\{partner\.([^}]+)\}\}/g)].map((m) => m[1]))]; + if (globalRefs.length === 0 && partnerRefs.length === 0) return null; + + const realPartners = partners.filter((p) => !p.isSystem); + const toggle = (key: string) => + setOpenKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + return ( +
+ {globalRefs.map((token) => { + const ref = globals.find((g) => g.token === token); + const setId = token.match(/\{\{globals\.([^.]+)\./)?.[1]; + return ( +
+ + {token} + + + {ref ? ( + + {ref.value} + + ) : ( + not found — check the set and key + )} +
+ ); + })} + + {partnerRefs.map((key) => { + const withValue = realPartners.filter((p) => p.propertyKeys.includes(key)); + const without = realPartners.length - withValue.length; + const open = openKeys.has(key); + return ( +
+ + {open && ( +
    + {withValue.map((p) => ( +
  • + + {p.name} + + + +
  • + ))} + {without > 0 && ( +
  • + {without} partner{without === 1 ? " doesn't" : "s don't"} set it — their exchanges resolve it + empty. +
  • + )} +
+ )} +
+ ); + })} +
+ ); +} + +/** One adapter property: grows with content, can insert reference tokens. */ +function PropField({ + prop, + value, + disabled, + onChange, +}: { + prop: AdapterInfo["props"][number]; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const { globals, partnerKeys, partners } = useReferenceTokens(); + const textareaRef = useRef(null); + + // Whether the user is part-way through entering a value. Masking has to key off + // this rather than off whether the field currently holds text: the latter flipped + // to masked on the very first keystroke, so typing a secret hid what you'd typed. + const [entering, setEntering] = useState(false); + const lastEmitted = useRef(null); + + // The parent re-supplies values from the server after a save or a discard. When + // what arrives isn't what this field last emitted, the draft was reset from the + // outside, so a stored secret goes back to being masked. + useEffect(() => { + if (value !== lastEmitted.current) setEntering(false); + }, [value]); + + const emit = (next: string) => { + lastEmitted.current = next; + setEntering(true); + onChange(next); + }; + + const masked = prop.secret && !!value && !entering; + + // Insert at the cursor (or over the current selection) instead of always + // appending, so picking a second reference doesn't just tack it onto the end. + const insertToken = (token: string) => { + const el = textareaRef.current; + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + emit(value.slice(0, start) + token + value.slice(end)); + const caret = start + token.length; + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(caret, caret); + }); + }; + + return ( + + {masked ? ( +
+ •••••••• + {!disabled && ( + + )} +
+ ) : ( +
+
+