From 02e46e8d0d1e450aebd862e50791a171a0cd3c3e Mon Sep 17 00:00:00 2001 From: cryptooda Date: Mon, 17 Nov 2025 20:04:17 +0700 Subject: [PATCH] Add paginated user retrieval functionality in AdminController and related services. Implemented UsersFilter for filtering user queries and added LastConnectionDate property to User model. Updated database schema and frontend API to support new user management features. --- .cursor/rules/fullstack.mdc | 2 +- .../Controllers/AdminController.cs | 89 + ...PaginatedBundleBacktestRequestsResponse.cs | 56 + .../Repositories/IUserRepository.cs | 5 +- .../Services/IUserService.cs | 5 +- .../Shared/BundleBacktestRequestsFilter.cs | 26 + src/Managing.Application/Users/UserService.cs | 20 +- src/Managing.Common/Enums.cs | 11 + src/Managing.Domain/Users/User.cs | 2 + ...3_AddLastConnectionDateToUsers.Designer.cs | 1726 +++++++++++++++++ ...1117115213_AddLastConnectionDateToUsers.cs | 29 + .../ManagingDbContextModelSnapshot.cs | 3 + .../PostgreSql/Entities/UserEntity.cs | 1 + .../PostgreSql/PostgreSqlUserRepository.cs | 75 + .../src/generated/ManagingApi.ts | 86 + .../src/generated/ManagingApiTypes.ts | 29 + .../src/hooks/useAdminClient.tsx | 9 + .../src/pages/adminPage/admin.tsx | 6 + .../pages/adminPage/users/usersSettings.tsx | 188 ++ .../src/pages/adminPage/users/usersTable.tsx | 197 ++ 20 files changed, 2559 insertions(+), 6 deletions(-) create mode 100644 src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.Designer.cs create mode 100644 src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.cs create mode 100644 src/Managing.WebApp/src/hooks/useAdminClient.tsx create mode 100644 src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx create mode 100644 src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx diff --git a/.cursor/rules/fullstack.mdc b/.cursor/rules/fullstack.mdc index 6e1238b3..4fd682cb 100644 --- a/.cursor/rules/fullstack.mdc +++ b/.cursor/rules/fullstack.mdc @@ -97,4 +97,4 @@ Key Principles - when dividing, make sure variable is not zero - to test a single ts test you can run : npm run test:single test/plugins/test-name-file.test.tsx - do not implement business logic on the controller, keep the business logic for Service files - + - When adding new property to and Orleans state, always add the property after the last one and increment the id diff --git a/src/Managing.Api/Controllers/AdminController.cs b/src/Managing.Api/Controllers/AdminController.cs index ff36fa78..32899017 100644 --- a/src/Managing.Api/Controllers/AdminController.cs +++ b/src/Managing.Api/Controllers/AdminController.cs @@ -263,5 +263,94 @@ public class AdminController : BaseController RelatedBacktestsDeleted = backtestsDeleted }); } + + /// + /// Retrieves paginated users for admin users. + /// This endpoint returns all users with all their properties. + /// + /// Page number (defaults to 1) + /// Number of items per page (defaults to 50, max 100) + /// Field to sort by (defaults to "Id") + /// Sort order - "asc" or "desc" (defaults to "desc") + /// Filter by user name contains + /// Filter by owner address contains + /// Filter by agent name contains + /// Filter by telegram channel contains + /// A paginated list of users. + [HttpGet] + [Route("Users/Paginated")] + public async Task> GetUsersPaginated( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] UserSortableColumn sortBy = UserSortableColumn.Id, + [FromQuery] string sortOrder = "desc", + [FromQuery] string? userNameContains = null, + [FromQuery] string? ownerAddressContains = null, + [FromQuery] string? agentNameContains = null, + [FromQuery] string? telegramChannelContains = null) + { + if (!await IsUserAdmin()) + { + _logger.LogWarning("Non-admin user attempted to access admin users endpoint"); + return StatusCode(403, new { error = "Only admin users can access this endpoint" }); + } + + if (page < 1) + { + return BadRequest("Page must be greater than 0"); + } + + if (pageSize < 1 || pageSize > 100) + { + return BadRequest("Page size must be between 1 and 100"); + } + + if (sortOrder != "asc" && sortOrder != "desc") + { + return BadRequest("Sort order must be 'asc' or 'desc'"); + } + + // Build filter + var filter = new UsersFilter + { + UserNameContains = string.IsNullOrWhiteSpace(userNameContains) ? null : userNameContains.Trim(), + OwnerAddressContains = string.IsNullOrWhiteSpace(ownerAddressContains) ? null : ownerAddressContains.Trim(), + AgentNameContains = string.IsNullOrWhiteSpace(agentNameContains) ? null : agentNameContains.Trim(), + TelegramChannelContains = string.IsNullOrWhiteSpace(telegramChannelContains) ? null : telegramChannelContains.Trim() + }; + + var (users, totalCount) = + await _userService.GetUsersPaginatedAsync( + page, + pageSize, + sortBy, + sortOrder, + filter); + + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + var response = new PaginatedUsersResponse + { + Users = users.Select(u => new UserListItemResponse + { + Id = u.Id, + Name = u.Name, + AgentName = u.AgentName, + AvatarUrl = u.AvatarUrl, + TelegramChannel = u.TelegramChannel, + OwnerWalletAddress = u.OwnerWalletAddress, + IsAdmin = u.IsAdmin, + LastConnectionDate = u.LastConnectionDate + }), + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = totalPages, + HasNextPage = page < totalPages, + HasPreviousPage = page > 1 + }; + + return Ok(response); + } } diff --git a/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs b/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs index e6ee1beb..a95bf0a8 100644 --- a/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs +++ b/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs @@ -42,6 +42,47 @@ public class PaginatedBundleBacktestRequestsResponse public bool HasPreviousPage { get; set; } } +/// +/// Response model for paginated users +/// +public class PaginatedUsersResponse +{ + /// + /// The list of users for the current page + /// + public IEnumerable Users { get; set; } = new List(); + + /// + /// Total number of users across all pages + /// + public int TotalCount { get; set; } + + /// + /// Current page number + /// + public int CurrentPage { get; set; } + + /// + /// Number of items per page + /// + public int PageSize { get; set; } + + /// + /// Total number of pages + /// + public int TotalPages { get; set; } + + /// + /// Whether there are more pages available + /// + public bool HasNextPage { get; set; } + + /// + /// Whether there are previous pages available + /// + public bool HasPreviousPage { get; set; } +} + /// /// Response model for a bundle backtest request list item (summary view) /// @@ -65,4 +106,19 @@ public class BundleBacktestRequestListItemResponse public int? EstimatedTimeRemainingSeconds { get; set; } } +/// +/// Response model for a user list item (summary view) +/// +public class UserListItemResponse +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string AgentName { get; set; } = string.Empty; + public string AvatarUrl { get; set; } = string.Empty; + public string TelegramChannel { get; set; } = string.Empty; + public string OwnerWalletAddress { get; set; } = string.Empty; + public bool IsAdmin { get; set; } + public DateTimeOffset? LastConnectionDate { get; set; } +} + diff --git a/src/Managing.Application.Abstractions/Repositories/IUserRepository.cs b/src/Managing.Application.Abstractions/Repositories/IUserRepository.cs index 614edd15..e6f15ecd 100644 --- a/src/Managing.Application.Abstractions/Repositories/IUserRepository.cs +++ b/src/Managing.Application.Abstractions/Repositories/IUserRepository.cs @@ -1,4 +1,6 @@ -using Managing.Domain.Users; +using Managing.Application.Abstractions.Shared; +using Managing.Domain.Users; +using static Managing.Common.Enums; namespace Managing.Application.Abstractions.Repositories; @@ -8,5 +10,6 @@ public interface IUserRepository Task GetUserByNameAsync(string name, bool fetchAccounts = false); Task GetUserByIdAsync(int userId); Task> GetAllUsersAsync(); + Task<(IEnumerable Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter); Task SaveOrUpdateUserAsync(User user); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IUserService.cs b/src/Managing.Application.Abstractions/Services/IUserService.cs index 4c99dec1..a11a8f27 100644 --- a/src/Managing.Application.Abstractions/Services/IUserService.cs +++ b/src/Managing.Application.Abstractions/Services/IUserService.cs @@ -1,4 +1,6 @@ -using Managing.Domain.Users; +using Managing.Application.Abstractions.Shared; +using Managing.Domain.Users; +using static Managing.Common.Enums; namespace Managing.Application.Abstractions.Services; @@ -13,4 +15,5 @@ public interface IUserService Task GetUserByAgentName(string agentName); Task GetUserByIdAsync(int userId); Task> GetAllUsersAsync(); + Task<(IEnumerable Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs b/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs index 6f3fd733..ce598d2d 100644 --- a/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs +++ b/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs @@ -68,4 +68,30 @@ public class BundleBacktestRequestsFilter public DateTime? CreatedAtTo { get; set; } } +/// +/// Filter model for users +/// +public class UsersFilter +{ + /// + /// Filter by user name contains (case-insensitive) + /// + public string? UserNameContains { get; set; } + + /// + /// Filter by owner address contains (case-insensitive) + /// + public string? OwnerAddressContains { get; set; } + + /// + /// Filter by agent name contains (case-insensitive) + /// + public string? AgentNameContains { get; set; } + + /// + /// Filter by telegram channel contains (case-insensitive) + /// + public string? TelegramChannelContains { get; set; } +} + diff --git a/src/Managing.Application/Users/UserService.cs b/src/Managing.Application/Users/UserService.cs index ecad1f7f..25b39c17 100644 --- a/src/Managing.Application/Users/UserService.cs +++ b/src/Managing.Application/Users/UserService.cs @@ -2,11 +2,12 @@ using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; -using Managing.Common; +using Managing.Application.Abstractions.Shared; using Managing.Domain.Accounts; using Managing.Domain.Users; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using static Managing.Common.Enums; namespace Managing.Application.Users; @@ -113,6 +114,10 @@ public class UserService : IUserService await _userRepository.SaveOrUpdateUserAsync(user); } + // Update last connection date + user.LastConnectionDate = DateTimeOffset.UtcNow; + await _userRepository.SaveOrUpdateUserAsync(user); + return user; } else @@ -132,8 +137,8 @@ public class UserService : IUserService { Name = $"{name}-embedded", Key = recoveredAddress, - Exchange = Enums.TradingExchanges.Evm, - Type = Enums.AccountType.Privy + Exchange = TradingExchanges.Evm, + Type = AccountType.Privy }); user.Accounts = new List() @@ -158,6 +163,10 @@ public class UserService : IUserService // Don't throw here to avoid breaking the user creation process } } + + // Update last connection date for new user + user.LastConnectionDate = DateTimeOffset.UtcNow; + await _userRepository.SaveOrUpdateUserAsync(user); } return user; @@ -313,4 +322,9 @@ public class UserService : IUserService return user; } + + public async Task<(IEnumerable Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter) + { + return await _userRepository.GetUsersPaginatedAsync(page, pageSize, sortBy, sortOrder, filter); + } } \ No newline at end of file diff --git a/src/Managing.Common/Enums.cs b/src/Managing.Common/Enums.cs index c521677c..43810ebe 100644 --- a/src/Managing.Common/Enums.cs +++ b/src/Managing.Common/Enums.cs @@ -544,6 +544,17 @@ public static class Enums UpdatedAt } + /// + /// Sortable columns for user queries + /// + public enum UserSortableColumn + { + Id, + Name, + OwnerWalletAddress, + AgentName + } + /// /// Event types for agent summary updates /// diff --git a/src/Managing.Domain/Users/User.cs b/src/Managing.Domain/Users/User.cs index b9599e48..e301f7c7 100644 --- a/src/Managing.Domain/Users/User.cs +++ b/src/Managing.Domain/Users/User.cs @@ -21,4 +21,6 @@ public class User [Id(6)] public string OwnerWalletAddress { get; set; } = string.Empty; [Id(7)] public bool IsAdmin { get; set; } = false; + + [Id(8)] public DateTimeOffset? LastConnectionDate { get; set; } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.Designer.cs b/src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.Designer.cs new file mode 100644 index 00000000..91c88cb6 --- /dev/null +++ b/src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.Designer.cs @@ -0,0 +1,1726 @@ +// +using System; +using Managing.Infrastructure.Databases.PostgreSql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Managing.Infrastructure.Databases.Migrations +{ + [DbContext(typeof(ManagingDbContext))] + [Migration("20251117115213_AddLastConnectionDateToUsers")] + partial class AddLastConnectionDateToUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exchange") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsGmxInitialized") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Secret") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AgentSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveStrategiesCount") + .HasColumnType("integer"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BacktestCount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Losses") + .HasColumnType("integer"); + + b.Property("NetPnL") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Runtime") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalBalance") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("TotalFees") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("TotalPnL") + .HasColumnType("decimal(18,8)"); + + b.Property("TotalROI") + .HasColumnType("decimal(18,8)"); + + b.Property("TotalVolume") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Wins") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentName") + .IsUnique(); + + b.HasIndex("TotalPnL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("AgentSummaries"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BacktestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0)); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Fees") + .HasColumnType("decimal(18,8)"); + + b.Property("FinalPnl") + .HasColumnType("decimal(18,8)"); + + b.Property("GrowthPercentage") + .HasColumnType("decimal(18,8)"); + + b.Property("HodlPercentage") + .HasColumnType("decimal(18,8)"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndicatorsCount") + .HasColumnType("integer"); + + b.Property("IndicatorsCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitialBalance") + .HasColumnType("decimal(18,8)"); + + b.Property("MaxDrawdown") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,8)") + .HasDefaultValue(0m); + + b.Property("MaxDrawdownRecoveryTime") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0)); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("MoneyManagementJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NetPnl") + .HasColumnType("decimal(18,8)"); + + b.Property("PositionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("ScoreMessage") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("text"); + + b.Property("SharpeRatio") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,8)") + .HasDefaultValue(0m); + + b.Property("SignalsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StatisticsJson") + .HasColumnType("jsonb"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Timeframe") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("WinRate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("RequestId"); + + b.HasIndex("Score"); + + b.HasIndex("UserId"); + + b.HasIndex("RequestId", "Score"); + + b.HasIndex("UserId", "Name"); + + b.HasIndex("UserId", "Score"); + + b.HasIndex("UserId", "Ticker"); + + b.HasIndex("UserId", "Timeframe"); + + b.ToTable("Backtests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => + { + b.Property("Identifier") + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("AccumulatedRunTimeSeconds") + .HasColumnType("bigint"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Fees") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("LastStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStopTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LongPositionCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NetPnL") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Pnl") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Roi") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("ShortPositionCount") + .HasColumnType("integer"); + + b.Property("StartupTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("TradeLosses") + .HasColumnType("integer"); + + b.Property("TradeWins") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Volume") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.HasKey("Identifier"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BundleBacktestRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBacktests") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentBacktest") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DateTimeRangesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EstimatedTimeRemainingSeconds") + .HasColumnType("integer"); + + b.Property("FailedBacktests") + .HasColumnType("integer"); + + b.Property("MoneyManagementVariantsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProgressInfo") + .HasColumnType("text"); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("ResultsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TickerVariantsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalBacktests") + .HasColumnType("integer"); + + b.Property("UniversalConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.HasIndex("UserId", "Name", "Version"); + + b.ToTable("BundleBacktestRequests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.FundingRateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("Exchange") + .HasColumnType("integer"); + + b.Property("OpenInterest") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Rate") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Exchange"); + + b.HasIndex("Ticker"); + + b.HasIndex("Exchange", "Date"); + + b.HasIndex("Ticker", "Exchange"); + + b.HasIndex("Ticker", "Exchange", "Date") + .IsUnique(); + + b.ToTable("FundingRates"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.GeneticRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Balance") + .HasColumnType("decimal(18,8)"); + + b.Property("BestChromosome") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("BestFitness") + .HasColumnType("double precision"); + + b.Property("BestFitnessSoFar") + .HasColumnType("double precision"); + + b.Property("BestIndividual") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrossoverMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("CurrentGeneration") + .HasColumnType("integer"); + + b.Property("EligibleIndicatorsJson") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ElitismPercentage") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Generations") + .HasColumnType("integer"); + + b.Property("MaxTakeProfit") + .HasColumnType("double precision"); + + b.Property("MutationMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("MutationRate") + .HasColumnType("double precision"); + + b.Property("PopulationSize") + .HasColumnType("integer"); + + b.Property("ProgressInfo") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SelectionMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("GeneticRequests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CyclePeriods") + .HasColumnType("integer"); + + b.Property("FastPeriods") + .HasColumnType("integer"); + + b.Property("MinimumHistory") + .HasColumnType("integer"); + + b.Property("Multiplier") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Period") + .HasColumnType("integer"); + + b.Property("SignalPeriods") + .HasColumnType("integer"); + + b.Property("SignalType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SlowPeriods") + .HasColumnType("integer"); + + b.Property("SmoothPeriods") + .HasColumnType("integer"); + + b.Property("StochPeriods") + .HasColumnType("integer"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name"); + + b.ToTable("Indicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedWorkerId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BundleRequestId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FailureCategory") + .HasColumnType("integer"); + + b.Property("GeneticRequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsRetryable") + .HasColumnType("boolean"); + + b.Property("JobType") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("LastHeartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxRetries") + .HasColumnType("integer"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ProgressPercentage") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ResultJson") + .HasColumnType("jsonb"); + + b.Property("RetryAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BundleRequestId") + .HasDatabaseName("idx_bundle_request"); + + b.HasIndex("GeneticRequestId") + .HasDatabaseName("idx_genetic_request"); + + b.HasIndex("AssignedWorkerId", "Status") + .HasDatabaseName("idx_assigned_worker"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("idx_user_status"); + + b.HasIndex("Status", "JobType", "Priority", "CreatedAt") + .HasDatabaseName("idx_status_jobtype_priority_created"); + + b.ToTable("Jobs", (string)null); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Leverage") + .HasColumnType("decimal(18,8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StopLoss") + .HasColumnType("decimal(18,8)"); + + b.Property("TakeProfit") + .HasColumnType("decimal(18,8)"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("UserName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserName"); + + b.HasIndex("UserName", "Name"); + + b.ToTable("MoneyManagements"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.PositionEntity", b => + { + b.Property("Identifier") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("GasFees") + .HasColumnType("decimal(18,8)"); + + b.Property("Initiator") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitiatorIdentifier") + .HasColumnType("uuid"); + + b.Property("MoneyManagementJson") + .HasColumnType("text"); + + b.Property("NetPnL") + .HasColumnType("decimal(18,8)"); + + b.Property("OpenTradeId") + .HasColumnType("integer"); + + b.Property("OriginDirection") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfitAndLoss") + .HasColumnType("decimal(18,8)"); + + b.Property("SignalIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StopLossTradeId") + .HasColumnType("integer"); + + b.Property("TakeProfit1TradeId") + .HasColumnType("integer"); + + b.Property("TakeProfit2TradeId") + .HasColumnType("integer"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("UiFees") + .HasColumnType("decimal(18,8)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Identifier"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("InitiatorIdentifier"); + + b.HasIndex("OpenTradeId"); + + b.HasIndex("Status"); + + b.HasIndex("StopLossTradeId"); + + b.HasIndex("TakeProfit1TradeId"); + + b.HasIndex("TakeProfit2TradeId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Identifier"); + + b.ToTable("Positions"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LoopbackPeriod") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name"); + + b.ToTable("Scenarios"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioIndicatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorId") + .HasColumnType("integer"); + + b.Property("ScenarioId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IndicatorId"); + + b.HasIndex("ScenarioId", "IndicatorId") + .IsUnique(); + + b.ToTable("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SignalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandleJson") + .HasColumnType("text"); + + b.Property("Confidence") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndicatorName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SignalType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Identifier"); + + b.HasIndex("Status"); + + b.HasIndex("Ticker"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Date"); + + b.HasIndex("Identifier", "Date", "UserId") + .IsUnique(); + + b.ToTable("Signals"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SpotlightOverviewEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Identifier") + .HasColumnType("uuid"); + + b.Property("ScenarioCount") + .HasColumnType("integer"); + + b.Property("SpotlightsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("DateTime", "ScenarioCount"); + + b.ToTable("SpotlightOverviews"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SynthMinersLeaderboardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Asset") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBacktest") + .HasColumnType("boolean"); + + b.Property("MinersData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SignalDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeIncrement") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("SynthMinersLeaderboards"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SynthPredictionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Asset") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBacktest") + .HasColumnType("boolean"); + + b.Property("MinerUid") + .HasColumnType("integer"); + + b.Property("PredictionData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SignalDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeIncrement") + .HasColumnType("integer"); + + b.Property("TimeLength") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("SynthPredictions"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TopVolumeTickerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Exchange") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Exchange"); + + b.HasIndex("Ticker"); + + b.HasIndex("Date", "Rank"); + + b.HasIndex("Exchange", "Date"); + + b.ToTable("TopVolumeTickers"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExchangeOrderId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Leverage") + .HasColumnType("decimal(18,8)"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("decimal(18,8)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,8)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("TradeType") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("ExchangeOrderId"); + + b.HasIndex("Status"); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TraderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AverageLoss") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("AverageWin") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBestTrader") + .HasColumnType("boolean"); + + b.Property("Pnl") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Roi") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("TradeCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Winrate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + b.HasIndex("IsBestTrader"); + + b.HasIndex("Pnl"); + + b.HasIndex("Roi"); + + b.HasIndex("Winrate"); + + b.HasIndex("Address", "IsBestTrader") + .IsUnique(); + + b.HasIndex("IsBestTrader", "Roi"); + + b.HasIndex("IsBestTrader", "Winrate"); + + b.ToTable("Traders"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastConnectionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OwnerWalletAddress") + .HasColumnType("text"); + + b.Property("TelegramChannel") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("AgentName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WhitelistAccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmbeddedWallet") + .IsRequired() + .HasMaxLength(42) + .HasColumnType("character varying(42)"); + + b.Property("ExternalEthereumAccount") + .HasMaxLength(42) + .HasColumnType("character varying(42)"); + + b.Property("IsWhitelisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PrivyCreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivyId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TwitterAccount") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EmbeddedWallet") + .IsUnique(); + + b.HasIndex("ExternalEthereumAccount"); + + b.HasIndex("PrivyId") + .IsUnique(); + + b.HasIndex("TwitterAccount"); + + b.ToTable("WhitelistAccounts"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WorkerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DelayTicks") + .HasColumnType("bigint"); + + b.Property("ExecutionCount") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("WorkerType") + .IsUnique(); + + b.ToTable("Workers"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AccountEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AgentSummaryEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BacktestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BundleBacktestRequestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.GeneticRequestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.PositionEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "OpenTrade") + .WithMany() + .HasForeignKey("OpenTradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "StopLossTrade") + .WithMany() + .HasForeignKey("StopLossTradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "TakeProfit1Trade") + .WithMany() + .HasForeignKey("TakeProfit1TradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "TakeProfit2Trade") + .WithMany() + .HasForeignKey("TakeProfit2TradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("OpenTrade"); + + b.Navigation("StopLossTrade"); + + b.Navigation("TakeProfit1Trade"); + + b.Navigation("TakeProfit2Trade"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioIndicatorEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", "Indicator") + .WithMany("ScenarioIndicators") + .HasForeignKey("IndicatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", "Scenario") + .WithMany("ScenarioIndicators") + .HasForeignKey("ScenarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Indicator"); + + b.Navigation("Scenario"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SignalEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.Navigation("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.Navigation("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", b => + { + b.Navigation("Accounts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.cs b/src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.cs new file mode 100644 index 00000000..dfbc7aff --- /dev/null +++ b/src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Managing.Infrastructure.Databases.Migrations +{ + /// + public partial class AddLastConnectionDateToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastConnectionDate", + table: "Users", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastConnectionDate", + table: "Users"); + } + } +} diff --git a/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs b/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs index 0a31d81b..20a1e030 100644 --- a/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs +++ b/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs @@ -1413,6 +1413,9 @@ namespace Managing.Infrastructure.Databases.Migrations b.Property("IsAdmin") .HasColumnType("boolean"); + b.Property("LastConnectionDate") + .HasColumnType("timestamp with time zone"); + b.Property("Name") .IsRequired() .HasMaxLength(255) diff --git a/src/Managing.Infrastructure.Database/PostgreSql/Entities/UserEntity.cs b/src/Managing.Infrastructure.Database/PostgreSql/Entities/UserEntity.cs index d245e2d7..abca5f61 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/Entities/UserEntity.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/Entities/UserEntity.cs @@ -14,6 +14,7 @@ public class UserEntity public string? AvatarUrl { get; set; } public string? TelegramChannel { get; set; } public string? OwnerWalletAddress { get; set; } + public DateTimeOffset? LastConnectionDate { get; set; } public bool IsAdmin { get; set; } // Navigation properties diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlUserRepository.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlUserRepository.cs index 77f56c45..eea3b4fb 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlUserRepository.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlUserRepository.cs @@ -1,10 +1,12 @@ using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; +using Managing.Application.Abstractions.Shared; using Managing.Domain.Accounts; using Managing.Domain.Users; using Managing.Infrastructure.Databases.PostgreSql.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using static Managing.Common.Enums; namespace Managing.Infrastructure.Databases.PostgreSql; @@ -318,4 +320,77 @@ public class PostgreSqlUserRepository : BaseRepositoryWithLogging, IUserReposito } }, nameof(SaveOrUpdateUserAsync), ("userName", user.Name), ("userId", user.Id)); } + + public async Task<(IEnumerable Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter) + { + return await ExecuteWithLoggingAsync(async () => + { + try + { + await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context); + + var query = _context.Users.AsNoTracking(); + + // Apply filters + if (!string.IsNullOrWhiteSpace(filter.UserNameContains)) + { + query = query.Where(u => EF.Functions.ILike(u.Name, $"%{filter.UserNameContains.Trim()}%")); + } + + if (!string.IsNullOrWhiteSpace(filter.OwnerAddressContains)) + { + query = query.Where(u => EF.Functions.ILike(u.OwnerWalletAddress, $"%{filter.OwnerAddressContains.Trim()}%")); + } + + if (!string.IsNullOrWhiteSpace(filter.AgentNameContains)) + { + query = query.Where(u => EF.Functions.ILike(u.AgentName, $"%{filter.AgentNameContains.Trim()}%")); + } + + if (!string.IsNullOrWhiteSpace(filter.TelegramChannelContains)) + { + query = query.Where(u => EF.Functions.ILike(u.TelegramChannel, $"%{filter.TelegramChannelContains.Trim()}%")); + } + + // Get total count for pagination + var totalCount = await query.CountAsync().ConfigureAwait(false); + + // Apply sorting + query = sortBy switch + { + UserSortableColumn.Id => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id), + UserSortableColumn.Name => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.Name) : query.OrderBy(u => u.Name), + UserSortableColumn.OwnerWalletAddress => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.OwnerWalletAddress) : query.OrderBy(u => u.OwnerWalletAddress), + UserSortableColumn.AgentName => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.AgentName) : query.OrderBy(u => u.AgentName), + _ => query.OrderBy(u => u.Id) // Default sorting + }; + + // Apply pagination + var users = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(u => new UserEntity + { + Id = u.Id, + Name = u.Name, + AgentName = u.AgentName, + AvatarUrl = u.AvatarUrl, + TelegramChannel = u.TelegramChannel, + OwnerWalletAddress = u.OwnerWalletAddress, + IsAdmin = u.IsAdmin, + LastConnectionDate = u.LastConnectionDate + }) + .ToListAsync() + .ConfigureAwait(false); + + var domainUsers = users.Select(PostgreSqlMappers.Map).ToList(); + + return (domainUsers, totalCount); + } + finally + { + await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context); + } + }, nameof(GetUsersPaginatedAsync), ("page", page), ("pageSize", pageSize), ("sortBy", sortBy), ("sortOrder", sortOrder)); + } } \ No newline at end of file diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 041beb35..3a9ded08 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -529,6 +529,63 @@ export class AdminClient extends AuthorizedApiBase { } return Promise.resolve(null as any); } + + admin_GetUsersPaginated(page: number | undefined, pageSize: number | undefined, sortBy: UserSortableColumn | undefined, sortOrder: string | null | undefined, userNameContains: string | null | undefined, ownerAddressContains: string | null | undefined, agentNameContains: string | null | undefined, telegramChannelContains: string | null | undefined): Promise { + let url_ = this.baseUrl + "/Admin/Users/Paginated?"; + if (page === null) + throw new Error("The parameter 'page' cannot be null."); + else if (page !== undefined) + url_ += "page=" + encodeURIComponent("" + page) + "&"; + if (pageSize === null) + throw new Error("The parameter 'pageSize' cannot be null."); + else if (pageSize !== undefined) + url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&"; + if (sortBy === null) + throw new Error("The parameter 'sortBy' cannot be null."); + else if (sortBy !== undefined) + url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&"; + if (sortOrder !== undefined && sortOrder !== null) + url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&"; + if (userNameContains !== undefined && userNameContains !== null) + url_ += "userNameContains=" + encodeURIComponent("" + userNameContains) + "&"; + if (ownerAddressContains !== undefined && ownerAddressContains !== null) + url_ += "ownerAddressContains=" + encodeURIComponent("" + ownerAddressContains) + "&"; + if (agentNameContains !== undefined && agentNameContains !== null) + url_ += "agentNameContains=" + encodeURIComponent("" + agentNameContains) + "&"; + if (telegramChannelContains !== undefined && telegramChannelContains !== null) + url_ += "telegramChannelContains=" + encodeURIComponent("" + telegramChannelContains) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processAdmin_GetUsersPaginated(_response); + }); + } + + protected processAdmin_GetUsersPaginated(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PaginatedUsersResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } } export class BacktestClient extends AuthorizedApiBase { @@ -4525,6 +4582,7 @@ export interface User { telegramChannel?: string | null; ownerWalletAddress?: string | null; isAdmin?: boolean; + lastConnectionDate?: Date | null; } export interface Balance { @@ -4767,6 +4825,34 @@ export interface BundleBacktestRequestStatusSummary { count?: number; } +export interface PaginatedUsersResponse { + users?: UserListItemResponse[]; + totalCount?: number; + currentPage?: number; + pageSize?: number; + totalPages?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; +} + +export interface UserListItemResponse { + id?: number; + name?: string; + agentName?: string; + avatarUrl?: string; + telegramChannel?: string; + ownerWalletAddress?: string; + isAdmin?: boolean; + lastConnectionDate?: Date | null; +} + +export enum UserSortableColumn { + Id = "Id", + Name = "Name", + OwnerWalletAddress = "OwnerWalletAddress", + AgentName = "AgentName", +} + export interface Backtest { id: string; finalPnl: number; diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index d503197f..beba62d9 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -48,6 +48,7 @@ export interface User { telegramChannel?: string | null; ownerWalletAddress?: string | null; isAdmin?: boolean; + lastConnectionDate?: Date | null; } export interface Balance { @@ -290,6 +291,34 @@ export interface BundleBacktestRequestStatusSummary { count?: number; } +export interface PaginatedUsersResponse { + users?: UserListItemResponse[]; + totalCount?: number; + currentPage?: number; + pageSize?: number; + totalPages?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; +} + +export interface UserListItemResponse { + id?: number; + name?: string; + agentName?: string; + avatarUrl?: string; + telegramChannel?: string; + ownerWalletAddress?: string; + isAdmin?: boolean; + lastConnectionDate?: Date | null; +} + +export enum UserSortableColumn { + Id = "Id", + Name = "Name", + OwnerWalletAddress = "OwnerWalletAddress", + AgentName = "AgentName", +} + export interface Backtest { id: string; finalPnl: number; diff --git a/src/Managing.WebApp/src/hooks/useAdminClient.tsx b/src/Managing.WebApp/src/hooks/useAdminClient.tsx new file mode 100644 index 00000000..fb1aa136 --- /dev/null +++ b/src/Managing.WebApp/src/hooks/useAdminClient.tsx @@ -0,0 +1,9 @@ +import {AdminClient} from '../generated/ManagingApi' +import useApiUrlStore from '../app/store/apiStore' + +export const useAdminClient = () => { + const { apiUrl } = useApiUrlStore() + const client = new AdminClient({}, apiUrl) + + return { client } +} diff --git a/src/Managing.WebApp/src/pages/adminPage/admin.tsx b/src/Managing.WebApp/src/pages/adminPage/admin.tsx index c0322f35..e75c546a 100644 --- a/src/Managing.WebApp/src/pages/adminPage/admin.tsx +++ b/src/Managing.WebApp/src/pages/adminPage/admin.tsx @@ -6,6 +6,7 @@ import AccountSettings from './account/accountSettings' import WhitelistSettings from './whitelist/whitelistSettings' import JobsSettings from './jobs/jobsSettings' import BundleBacktestRequestsSettings from './bundleBacktestRequests/bundleBacktestRequestsSettings' +import UsersSettings from './users/usersSettings' type TabsType = { label: string @@ -35,6 +36,11 @@ const tabs: TabsType = [ index: 4, label: 'Bundle', }, + { + Component: UsersSettings, + index: 5, + label: 'Users', + }, ] const Admin: React.FC = () => { diff --git a/src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx b/src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx new file mode 100644 index 00000000..60151c09 --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx @@ -0,0 +1,188 @@ +import {useState} from 'react' +import {useQuery} from '@tanstack/react-query' + +import {useAdminClient} from '../../../hooks/useAdminClient' +import {UserSortableColumn,} from '../../../generated/ManagingApi' + +import UsersTable from './usersTable' + +const UsersSettings: React.FC = () => { + const { client } = useAdminClient() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(50) + const [sortBy, setSortBy] = useState(UserSortableColumn.Id) + const [sortOrder, setSortOrder] = useState('desc') + const [userNameContains, setUserNameContains] = useState('') + const [ownerAddressContains, setOwnerAddressContains] = useState('') + const [agentNameContains, setAgentNameContains] = useState('') + const [telegramChannelContains, setTelegramChannelContains] = useState('') + const [filtersOpen, setFiltersOpen] = useState(false) + + const { + data: usersData, + isLoading, + error, + } = useQuery({ + queryKey: ['users', page, pageSize, sortBy, sortOrder, userNameContains, ownerAddressContains, agentNameContains, telegramChannelContains], + queryFn: async () => { + return await client.admin_GetUsersPaginated( + page, + pageSize, + sortBy, + sortOrder || null, + userNameContains || null, + ownerAddressContains || null, + agentNameContains || null, + telegramChannelContains || null + ) + }, + staleTime: 10000, // 10 seconds + gcTime: 5 * 60 * 1000, + }) + + const users = usersData?.users || [] + const totalCount = usersData?.totalCount || 0 + const totalPages = usersData?.totalPages || 0 + const currentPage = usersData?.currentPage || 1 + + const handlePageChange = (newPage: number) => { + setPage(newPage) + } + + const handleSortChange = (newSortBy: UserSortableColumn) => { + if (sortBy === newSortBy) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(newSortBy) + setSortOrder('desc') + } + } + + const handleFilterChange = () => { + setPage(1) // Reset to first page when filters change + } + + const clearFilters = () => { + setUserNameContains('') + setOwnerAddressContains('') + setAgentNameContains('') + setTelegramChannelContains('') + setPage(1) + } + + return ( +
+ {/* Filters Section */} +
+
+
+

Filters

+
+ + +
+
+ + {filtersOpen && ( +
+
+ + { + setUserNameContains(e.target.value) + handleFilterChange() + }} + /> +
+ +
+ + { + setOwnerAddressContains(e.target.value) + handleFilterChange() + }} + /> +
+ +
+ + { + setAgentNameContains(e.target.value) + handleFilterChange() + }} + /> +
+ +
+ + { + setTelegramChannelContains(e.target.value) + handleFilterChange() + }} + /> +
+
+ )} +
+
+ + + + {error && ( +
+ Failed to load users. Please try again. +
+ )} +
+ ) +} + +export default UsersSettings diff --git a/src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx b/src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx new file mode 100644 index 00000000..164f444b --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx @@ -0,0 +1,197 @@ +import React, {useMemo} from 'react' +import {type UserListItemResponse, UserSortableColumn} from '../../../generated/ManagingApi' +import {Table, Toast} from '../../../components/mollecules' + +interface IUsersTable { + users: UserListItemResponse[] + isLoading: boolean + totalCount: number + currentPage: number + totalPages: number + pageSize: number + sortBy: UserSortableColumn + sortOrder: string + onPageChange: (page: number) => void + onSortChange: (sortBy: UserSortableColumn) => void +} + +const UsersTable: React.FC = ({ + users, + isLoading, + totalCount, + currentPage, + totalPages, + pageSize, + sortBy, + sortOrder, + onPageChange, + onSortChange +}) => { + const formatDate = (date: Date | string | null | undefined) => { + if (!date) return '-' + try { + return new Date(date).toLocaleString() + } catch { + return '-' + } + } + + const copyToClipboard = async (text: string, label: string) => { + const toast = new Toast(`Copying ${label} to clipboard...`) + try { + await navigator.clipboard.writeText(text) + toast.update('success', `${label} copied to clipboard!`) + } catch (err) { + toast.update('error', 'Failed to copy to clipboard') + } + } + + const truncateAddress = (address: string | null | undefined) => { + if (!address) return '-' + if (address.length <= 12) return address + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` + } + + const SortableHeader = ({ column, label }: { column: UserSortableColumn; label: string }) => { + const isActive = sortBy === column + return ( +
onSortChange(column)} + > + {label} + {isActive && ( + + {sortOrder === 'asc' ? '↑' : '↓'} + + )} +
+ ) + } + + const columns = useMemo(() => [ + { + id: 'id', + Header: () => , + accessor: (user: UserListItemResponse) => ( + {user.id?.toString() || '-'} + ) + }, + { + id: 'name', + Header: () => , + accessor: (user: UserListItemResponse) => ( +
+ {user.name || '-'} + {user.name && ( + + )} +
+ ) + }, + { + id: 'agentName', + Header: () => , + accessor: (user: UserListItemResponse) => ( + {user.agentName || '-'} + ) + }, + { + id: 'ownerWalletAddress', + Header: () => , + accessor: (user: UserListItemResponse) => ( +
+ {truncateAddress(user.ownerWalletAddress)} + {user.ownerWalletAddress && ( + + )} +
+ ) + }, + { + id: 'telegramChannel', + Header: 'Telegram Channel', + accessor: (user: UserListItemResponse) => ( + {user.telegramChannel || '-'} + ) + }, + { + id: 'isAdmin', + Header: 'Admin', + accessor: (user: UserListItemResponse) => ( + + {user.isAdmin ? 'Admin' : 'User'} + + ) + }, + { + id: 'avatarUrl', + Header: 'Avatar', + accessor: (user: UserListItemResponse) => ( + user.avatarUrl ? ( +
+
+ Avatar +
+
+ ) : ( + - + ) + ) + }, + { + id: 'lastConnectionDate', + Header: 'Last Connection', + accessor: (user: UserListItemResponse) => ( + + {formatDate(user.lastConnectionDate)} + + ) + } + ], [sortBy, sortOrder, onSortChange]) + + return ( +
+
+
+

+ + + + Users ({totalCount}) +

+
+ + + + + ) +} + +export default UsersTable