feat(Backend): update API gateway and websocket hubs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user