feat(Backend): update API gateway and websocket hubs

This commit is contained in:
2026-08-09 21:32:52 +02:00
parent fdf4b6efcb
commit a9553e9fbf
66 changed files with 188878 additions and 0 deletions
@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticBackend.Database;
using FinlyticBackend.Entities;
using FinlyticBackend.Hubs;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.TechnicalAnalysis;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
/// <summary>
/// Background service that periodically (every 10 seconds) queries FinlyticTechnicalAnalysis over MQTT RPC
/// to retrieve current close prices and daily % growth for all favorited assets, and broadcasts the updates
/// via SignalR to connected clients.
/// </summary>
public class FavoritesPriceBackgroundService : BackgroundService
{
private readonly IHubContext<FavoritesPriceHub> _hubContext;
private readonly WebMqttClient _mqttClient;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FavoritesPriceBackgroundService> _logger;
private readonly Random _random = new();
public FavoritesPriceBackgroundService(
IHubContext<FavoritesPriceHub> hubContext,
WebMqttClient mqttClient,
IServiceScopeFactory scopeFactory,
ILogger<FavoritesPriceBackgroundService> logger)
{
_hubContext = hubContext;
_mqttClient = mqttClient;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[FavoritesPriceBackgroundService] Started 10-second periodic price & daily growth stream.");
await Task.Delay(4000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var priceUpdates = await FetchFavoritePricesAsync(stoppingToken);
if (priceUpdates.Count > 0)
{
await _hubContext.Clients.All.SendAsync("ReceiveFavoritePrices", priceUpdates, cancellationToken: stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[FavoritesPriceBackgroundService] Error broadcasting 10s favorite price updates.");
}
await Task.Delay(10000, stoppingToken);
}
}
private async Task<Dictionary<string, object>> FetchFavoritePricesAsync(CancellationToken cancellationToken)
{
var priceMap = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
List<UserFavoriteAssetEntity> favorites = new();
try
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
favorites = await dbContext.UserFavoriteAssets
.AsNoTracking()
.ToListAsync(cancellationToken);
}
catch { }
// De-duplicate by ISIN (preferring entries that have SelectedTicker)
var dedupedFavorites = favorites
.GroupBy(f => f.Isin.Trim().ToUpperInvariant())
.Select(g => g.OrderByDescending(f => !string.IsNullOrEmpty(f.SelectedTicker)).First())
.ToList();
foreach (var fav in dedupedFavorites)
{
string cleanIsin = fav.Isin.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(cleanIsin)) continue;
string querySymbol = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker.Trim().ToUpperInvariant() : cleanIsin;
double currentPrice = 0.0;
double dailyChangePercent = 0.0;
bool resolvedFromTa = false;
try
{
if (_mqttClient.IsConnected)
{
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
"tr_GetLivePrice",
new IsinRequest(querySymbol),
TimeSpan.FromSeconds(2)
);
if (livePriceDto != null)
{
currentPrice = (double)livePriceDto.CurrentPrice;
dailyChangePercent = (double)livePriceDto.DailyChangePercent;
resolvedFromTa = true;
}
}
}
catch { }
if (resolvedFromTa)
{
priceMap[cleanIsin] = new
{
isin = cleanIsin,
symbol = querySymbol,
currentPrice = currentPrice,
dailyChangePercent = dailyChangePercent
};
}
}
return priceMap;
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models.Trades;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
/// <summary>
/// Defines a service for dispatching push notifications via Firebase Cloud Messaging (FCM).
/// </summary>
public interface IFirebaseNotificationService
{
/// <summary>
/// Sends a push notification about a new trade proposal.
/// </summary>
/// <param name="proposal">The trade proposal details.</param>
/// <param name="fcmTokens">The list of FCM device tokens.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a push notification about an update to an existing trade.
/// </summary>
/// <param name="update">The trade update details.</param>
/// <param name="fcmTokens">The list of FCM device tokens.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
}
/// <inheritdoc />
public class FirebaseNotificationService : IFirebaseNotificationService
{
private readonly HttpClient _httpClient;
private readonly ILogger<FirebaseNotificationService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FirebaseNotificationService"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client for making API requests.</param>
/// <param name="logger">The logger instance.</param>
public FirebaseNotificationService(HttpClient httpClient, ILogger<FirebaseNotificationService> logger)
{
_httpClient = httpClient;
_logger = logger;
}
/// <inheritdoc />
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
string title = $"🚀 Trade Signal: {proposal.SignalType} {proposal.Symbol}";
string body = $"{proposal.CompanyName} ({proposal.Isin}) - Entry: ${proposal.EntryPrice:F2}, WinRate: {proposal.WinRate:F1}%. {proposal.Reasoning}";
foreach (var token in fcmTokens)
{
await DispatchFcmMessageAsync(token, title, body, cancellationToken);
}
}
/// <inheritdoc />
public async Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
string title = $"📊 Trade Update: {update.TradeId}";
string body = $"Recommendation: {update.Recommendation} @ ${update.CurrentPrice:F2}. {update.Reasoning}";
foreach (var token in fcmTokens)
{
await DispatchFcmMessageAsync(token, title, body, cancellationToken);
}
}
private Task DispatchFcmMessageAsync(string fcmToken, string title, string body, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("[{Channel}] FCM Push Notification dispatched to Token [{TokenPrefix}...]: Title='{Title}', Body='{Body}'",
"NotificationChannel", fcmToken.Length > 10 ? fcmToken[..10] : fcmToken, title, body);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Failed to send FCM push notification to token {Token}", "NotificationChannel", fcmToken);
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FinlyticBackend.Entities;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace FinlyticBackend.Services;
/// <summary>
/// Service for generating JWT tokens for user authentication.
/// </summary>
public interface IJwtTokenService
{
/// <summary>
/// Generates a JWT token for the specified user.
/// </summary>
/// <param name="user">The user entity.</param>
/// <returns>A tuple containing the generated token string and its expiration date.</returns>
(string token, DateTime expiresAt) GenerateToken(UserEntity user);
}
/// <inheritdoc />
public class JwtTokenService : IJwtTokenService
{
private readonly string _secretKey;
private readonly string _issuer;
private readonly string _audience;
private readonly int _expiryDays;
/// <summary>
/// Initializes a new instance of the <see cref="JwtTokenService"/> class.
/// </summary>
/// <param name="configuration">The application configuration.</param>
public JwtTokenService(IConfiguration configuration)
{
var configuredKey = configuration["JWT:SecretKey"] ?? configuration["JWT__SecretKey"];
// Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits)
if (string.IsNullOrWhiteSpace(configuredKey) || configuredKey.Length < 32)
{
_secretKey = "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
}
else
{
_secretKey = configuredKey;
}
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
_audience = configuration["JWT:Audience"] ?? configuration["JWT__Audience"] ?? "FinlyticClients";
_expiryDays = int.TryParse(configuration["JWT:ExpiryDays"] ?? configuration["JWT__ExpiryDays"], out var days) ? days : 7;
}
/// <inheritdoc />
public (string token, DateTime expiresAt) GenerateToken(UserEntity user)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_secretKey);
var expiresAt = DateTime.UtcNow.AddDays(_expiryDays);
string userIdStr = user.Id.ToString();
string emailStr = user.Email ?? string.Empty;
string nameStr = user.FullName ?? string.Empty;
string roleStr = string.IsNullOrWhiteSpace(user.Role) ? "User" : user.Role;
// Bündelung von ASP.NET Core ClaimTypes UND OAuth2/OpenID Standard-Claims (sub, email, role, name)
var claims = new List<Claim>
{
// Standard ASP.NET Core Claims
new(ClaimTypes.NameIdentifier, userIdStr),
new(ClaimTypes.Email, emailStr),
new(ClaimTypes.Name, nameStr),
new(ClaimTypes.Role, roleStr),
// OpenID / OAuth2 Short Claims für Frontend/Mobile Clients (Flutter/Avalonia)
new(JwtRegisteredClaimNames.Sub, userIdStr),
new(JwtRegisteredClaimNames.Email, emailStr),
new(JwtRegisteredClaimNames.Name, nameStr),
new("role", roleStr),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = expiresAt,
Issuer = _issuer,
Audience = _audience,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return (tokenHandler.WriteToken(token), expiresAt);
}
}
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticBackend.Controllers;
using FinlyticBackend.Hubs;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
/// <summary>
/// Background service that continuously executes live MQTT RPC health pings across all microservices every 5 seconds
/// and broadcasts real-time health diagnostic updates to all connected SignalR clients on SystemHealthHub (AOT-compliant).
/// </summary>
public class SystemHealthBackgroundService : BackgroundService
{
private readonly IHubContext<SystemHealthHub> _hubContext;
private readonly WebMqttClient _mqttClient;
private readonly ILogger<SystemHealthBackgroundService> _logger;
public SystemHealthBackgroundService(
IHubContext<SystemHealthHub> hubContext,
WebMqttClient mqttClient,
ILogger<SystemHealthBackgroundService> logger)
{
_hubContext = hubContext;
_mqttClient = mqttClient;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[SystemHealthBackgroundService] Started periodic MQTT RPC health pings (Interval: 5s).");
// Initial delay to allow MQTT client to establish connection
await Task.Delay(3000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var healthData = await PerformHealthCheckAsync();
await _hubContext.Clients.All.SendAsync("ReceiveSystemHealth", healthData, cancellationToken: stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[SystemHealthBackgroundService] Error performing system health check broadcast.");
}
await Task.Delay(5000, stoppingToken);
}
}
/// <summary>
/// Executes health check pings and returns AOT-compliant DTOs.
/// </summary>
public async Task<List<ServiceHealthStatusDto>> PerformHealthCheckAsync()
{
var servicesToCheck = new (string Name, string Channel, string Type, string Db)[]
{
("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
("FinlyticTechnicalAnalysis", "health_Ping/FinlyticTechnicalAnalysis", "Technical Indicators (EMA/RSI)", "PostgreSQL ta"),
("FinlyticSentiment", "health_Ping/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
("FinlyticAnalyzer", "health_Ping/FinlyticAnalyzer", "Multi-Layer Signal Engine", "PostgreSQL analyzer"),
("FinlyticTrades", "health_Ping/FinlyticTrades", "Trade Lifecycle Manager", "PostgreSQL trades"),
("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
};
var results = new List<ServiceHealthStatusDto>
{
new(
Name: "FinlyticBackend",
Type: "REST API & SignalR Gateway",
Status: "Online",
Port: "5000",
Communication: "Kestrel HTTP / WebSocket",
Db: "PostgreSQL backend",
LastPing: DateTime.UtcNow
)
};
var tasks = servicesToCheck.Select(async s =>
{
try
{
if (_mqttClient.IsConnected)
{
var resp = await _mqttClient.SendRpcRequestAsync<ServiceHealthResponse, EmptyRequest>(
s.Channel,
new EmptyRequest(),
TimeSpan.FromMilliseconds(1200)
);
if (resp != null)
{
return new ServiceHealthStatusDto(
Name: s.Name,
Type: s.Type,
Status: "Online",
Port: "MQTT Only (No HTTP Port)",
Communication: "MQTT RPC & Pub/Sub",
Db: s.Db,
LastPing: resp.Timestamp
);
}
}
}
catch { }
return new ServiceHealthStatusDto(
Name: s.Name,
Type: s.Type,
Status: "Offline",
Port: "MQTT Only (No HTTP Port)",
Communication: "MQTT (No Response / Timeout)",
Db: s.Db,
LastPing: DateTime.UtcNow
);
});
var pingResults = await Task.WhenAll(tasks);
results.AddRange(pingResults);
return results;
}
}
+390
View File
@@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BCrypt.Net;
using FinlyticBackend.Database;
using FinlyticBackend.Entities;
using FinlyticCore.Models.Auth;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
/// <summary>
/// Interface for user management and authentication operations.
/// </summary>
public interface IUserService
{
/// <summary>Authenticates user credentials and returns JWT response.</summary>
Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Registers a new user account.</summary>
Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Changes the initial password for a user.</summary>
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default);
/// <summary>Creates a new user by an admin.</summary>
Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Updates an existing user account.</summary>
Task<UserDto?> UpdateUserAsync(Guid userId, UpdateUserRequestDto request,
CancellationToken cancellationToken = default);
/// <summary>Resets a user's password by admin.</summary>
Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken = default);
/// <summary>Registers or updates FCM device token.</summary>
Task<bool> RegisterOrUpdateFcmTokenAsync(Guid userId, string fcmToken, string deviceName,
CancellationToken cancellationToken = default);
/// <summary>Gets all registered users.</summary>
Task<List<UserDto>> GetAllUsersAsync(CancellationToken cancellationToken = default);
/// <summary>Deactivates a user account.</summary>
Task<bool> DeactivateUserAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>Seeds default admin account if not present.</summary>
Task SeedDefaultAdminAsync(string defaultAdminPassword, CancellationToken cancellationToken = default);
}
public class UserService : IUserService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IJwtTokenService _jwtTokenService;
private readonly ILogger<UserService> _logger;
public UserService(
IServiceScopeFactory scopeFactory,
IJwtTokenService jwtTokenService,
ILogger<UserService> logger)
{
_scopeFactory = scopeFactory;
_jwtTokenService = jwtTokenService;
_logger = logger;
}
/// <inheritdoc />
public async Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users
.Include(u => u.DeviceTokens)
.FirstOrDefaultAsync(u => u.Email.ToLower() == request.Email.ToLower(), cancellationToken);
if (user == null || !user.IsActive)
{
_logger.LogWarning("[{Channel}] Authentication failed: User '{Email}' not found or inactive.",
"AuthChannel", request.Email);
return null;
}
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
_logger.LogWarning("[{Channel}] Authentication failed: Invalid password for '{Email}'.", "AuthChannel",
request.Email);
return null;
}
user.LastLoginAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
var (token, expiresAt) = _jwtTokenService.GenerateToken(user);
return new AuthResponseDto
{
Token = token,
UserId = user.Id,
Email = user.Email,
FullName = user.FullName,
Role = user.Role,
ThemePreference =
string.IsNullOrWhiteSpace(user.ThemePreference)
? "fluent_dark"
: user.ThemePreference,
FcmTokens = user.DeviceTokens.Select(t => t.FcmToken).ToList(),
ExpiresAt = expiresAt,
RequiresPasswordChange = user.RequiresPasswordChange
};
}
/// <inheritdoc />
public async Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
if (exists)
{
_logger.LogWarning("[{Channel}] Registration failed: Email '{Email}' is already taken.", "AuthChannel",
request.Email);
return null;
}
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
var newUser = new UserEntity
{
Email = normalizedEmail,
PasswordHash = passwordHash,
FullName = string.IsNullOrWhiteSpace(request.FullName) ? normalizedEmail.Split('@')[0] : request.FullName,
Role = "User",
ThemePreference = "fluent_dark", // Default Theme for FluentAvalonia
IsActive = true,
CreatedAt = DateTime.UtcNow,
LastLoginAt = DateTime.UtcNow
};
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);
var (token, expiresAt) = _jwtTokenService.GenerateToken(newUser);
return new AuthResponseDto
{
Token = token,
UserId = newUser.Id,
Email = newUser.Email,
FullName = newUser.FullName,
Role = newUser.Role,
ThemePreference = newUser.ThemePreference,
FcmTokens = new List<string>(),
ExpiresAt = expiresAt
};
}
/// <inheritdoc />
public async Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
if (exists)
{
_logger.LogWarning("[{Channel}] Cannot create user: Email '{Email}' already exists.", "AuthChannel",
request.Email);
return null;
}
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
var newUser = new UserEntity
{
Email = normalizedEmail,
PasswordHash = passwordHash,
FullName = request.FullName,
Role = string.Equals(request.Role, "Admin", StringComparison.OrdinalIgnoreCase) ? "Admin" : "User",
ThemePreference = "fluent_dark",
IsActive = true,
RequiresPasswordChange = true,
CreatedAt = DateTime.UtcNow
};
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Admin created user '{Email}' with Role '{Role}'.", "AuthChannel",
newUser.Email, newUser.Role);
return MapToUserDto(newUser);
}
/// <inheritdoc />
public async Task<UserDto?> UpdateUserAsync(Guid userId, UpdateUserRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users
.Include(u => u.DeviceTokens)
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return null;
if (!string.IsNullOrWhiteSpace(request.Role))
{
user.Role = request.Role;
}
if (request.IsActive.HasValue)
{
user.IsActive = request.IsActive.Value;
}
if (!string.IsNullOrWhiteSpace(request.FullName))
{
user.FullName = request.FullName;
}
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Updated user '{UserId}' details.", "AuthChannel", userId);
return MapToUserDto(user);
}
/// <inheritdoc />
public async Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null || !user.RequiresPasswordChange) return false;
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.RequiresPasswordChange = false;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] User '{UserId}' changed their initial password.", "AuthChannel", userId);
return true;
}
/// <inheritdoc />
public async Task<bool> ResetPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return false;
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.RequiresPasswordChange = true;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Admin reset password for user '{UserId}'.", "AuthChannel", userId);
return true;
}
/// <inheritdoc />
public async Task<bool> RegisterOrUpdateFcmTokenAsync(Guid userId, string fcmToken, string deviceName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(fcmToken)) return false;
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users
.Include(u => u.DeviceTokens)
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null || !user.IsActive) return false;
var existingToken = user.DeviceTokens.FirstOrDefault(t => t.FcmToken == fcmToken);
if (existingToken != null)
{
existingToken.DeviceName = deviceName;
existingToken.LastUsedAt = DateTime.UtcNow;
}
else
{
user.DeviceTokens.Add(new UserDeviceTokenEntity
{
UserId = user.Id,
FcmToken = fcmToken,
DeviceName = deviceName,
RegisteredAt = DateTime.UtcNow,
LastUsedAt = DateTime.UtcNow
});
}
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc />
public async Task<List<UserDto>> GetAllUsersAsync(CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var users = await dbContext.Users
.AsNoTracking()
.Include(u => u.DeviceTokens)
.OrderByDescending(u => u.CreatedAt)
.ToListAsync(cancellationToken);
return users.Select(MapToUserDto).ToList();
}
/// <inheritdoc />
public async Task<bool> DeactivateUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return false;
user.IsActive = false;
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc />
public async Task SeedDefaultAdminAsync(string defaultAdminPassword, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
string adminEmail = "admin@finlytic.com";
bool exists = await dbContext.Users.AnyAsync(u => u.Email == adminEmail, cancellationToken);
if (!exists)
{
string password = !string.IsNullOrWhiteSpace(defaultAdminPassword)
? defaultAdminPassword
: "AdminDefaultPassword2026!";
var adminUser = new UserEntity
{
Email = adminEmail,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
FullName = "System Administrator",
Role = "Admin",
ThemePreference = "fluent_dark",
IsActive = true,
CreatedAt = DateTime.UtcNow
};
dbContext.Users.Add(adminUser);
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Seeded default Admin user: {Email}", "AuthChannel", adminEmail);
}
}
private static UserDto MapToUserDto(UserEntity user)
{
return new UserDto
{
Id = user.Id,
Email = user.Email,
FullName = user.FullName,
Role = user.Role,
IsActive = user.IsActive,
ThemePreference = string.IsNullOrWhiteSpace(user.ThemePreference) ? "fluent_dark" : user.ThemePreference,
FcmTokens = user.DeviceTokens?.Select(t => t.FcmToken).ToList() ?? new List<string>(),
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt
};
}
}