using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using FinlyticBackend.Database; using FinlyticBackend.Entities; using FinlyticBackend.Util; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Assets; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models.Assets; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace FinlyticBackend.Controllers; /// /// DTO representing a user's favorite asset. /// public record FavoriteAssetDto( [property: JsonPropertyName("symbol")] string Symbol, [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("isin")] string Isin, [property: JsonPropertyName("image")] string Image, [property: JsonPropertyName("currentPrice")] double CurrentPrice = 0.0, [property: JsonPropertyName("dailyChangePercent")] double DailyChangePercent = 0.0 ); [ApiController] [Authorize] [Route("api/v1/user/favorites")] public class UserFavoritesController : ControllerBase { private readonly BackendDbContext _dbContext; private readonly WebMqttClient _mqttClient; private readonly ILogger _logger; public UserFavoritesController( BackendDbContext dbContext, WebMqttClient mqttClient, ILogger logger) { _dbContext = dbContext; _mqttClient = mqttClient; _logger = logger; } /// /// Retrieves the user's favorite assets with parallelized RPC queries for maximum performance. /// [HttpGet] public async Task GetUserFavorites(CancellationToken cancellationToken) { if (!TryGetUserId(out var userId)) { return Unauthorized(new { message = "Invalid or expired user session." }); } var userFavorites = await _dbContext.UserFavoriteAssets .AsNoTracking() .Where(f => f.UserId == userId) .OrderByDescending(f => f.CreatedAt) .ToListAsync(cancellationToken); if (userFavorites.Count == 0) { return Ok(new List()); } // Parallele RPC-Abfragen für alle Favoriten gleichzeitig vorbereiten var tasks = userFavorites.Select(async fav => { string cleanIsin = fav.Isin.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(cleanIsin)) return null; string querySymbol = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker.Trim().ToUpperInvariant() : cleanIsin; double currentPrice = 0.0; double dailyChangePercent = 0.0; FavoriteAssetDto? resolvedAsset = null; // 1. Live Price & Asset-Details parallel via MQTT RPC abfragen var livePriceTask = FetchLivePriceAsync(querySymbol); var assetDetailsTask = FetchAssetDetailsAsync(cleanIsin, fav.SelectedTicker); await Task.WhenAll(livePriceTask, assetDetailsTask); var livePrice = await livePriceTask; if (livePrice.HasValue) { currentPrice = livePrice.Value.Price; dailyChangePercent = livePrice.Value.ChangePercent; } resolvedAsset = await assetDetailsTask; // 2. Fallback auf lokalen Index, falls Asset-Details über RPC fehlschlagen if (resolvedAsset == null) { var allAssets = AssetsController.LoadAssetsFromIndexJson(); var matched = allAssets.FirstOrDefault(a => a.Isin.Equals(cleanIsin, StringComparison.OrdinalIgnoreCase) || a.Name.Equals(cleanIsin, StringComparison.OrdinalIgnoreCase)); if (matched != null) { string isinCode = matched.Isin; string symbolCode = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker : isinCode; string name = string.IsNullOrWhiteSpace(matched.Name) ? isinCode : matched.Name; string image = !string.IsNullOrWhiteSpace(matched.Image) ? matched.Image : $"/api/v1/logo/{isinCode}"; resolvedAsset = new FavoriteAssetDto(symbolCode, name, isinCode, image, currentPrice, dailyChangePercent); } else { string symbolCode = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker : cleanIsin; resolvedAsset = new FavoriteAssetDto( symbolCode, cleanIsin, cleanIsin, $"/api/v1/logo/{cleanIsin}", currentPrice, dailyChangePercent ); } } return resolvedAsset; }); var resolvedList = await Task.WhenAll(tasks); // Deduplizierung var result = new List(); var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var asset in resolvedList) { if (asset == null) continue; string dedupKey = string.IsNullOrWhiteSpace(asset.Isin) ? asset.Symbol : asset.Isin; string canonicalKey = dedupKey.Trim().ToUpperInvariant(); if (seenKeys.Add(canonicalKey)) { seenKeys.Add(asset.Name.Trim().ToUpperInvariant()); result.Add(asset); } } return Ok(result); } /// /// Updates the selected ticker for a favorite asset. /// [HttpPost("{symbol}/ticker")] public async Task UpdateFavoriteTicker(string symbol, [FromQuery] string ticker, CancellationToken cancellationToken) { if (!TryGetUserId(out var userId)) { return Unauthorized(new { message = "Invalid or expired user session." }); } string inputQuery = symbol.Trim(); if (string.IsNullOrWhiteSpace(inputQuery) || string.IsNullOrWhiteSpace(ticker)) { return BadRequest(new { error = "Invalid symbol or ticker." }); } string canonicalIsin = ResolveCanonicalIsin(inputQuery); var favorite = await _dbContext.UserFavoriteAssets .FirstOrDefaultAsync( f => f.UserId == userId && (f.Isin == canonicalIsin || f.Isin == inputQuery.ToUpperInvariant()), cancellationToken); if (favorite != null) { favorite.SelectedTicker = ticker.Trim().ToUpperInvariant(); _dbContext.UserFavoriteAssets.Update(favorite); await _dbContext.SaveChangesAsync(cancellationToken); return Ok(new { success = true, isin = canonicalIsin, selectedTicker = favorite.SelectedTicker }); } return NotFound(new { error = "Asset is not in user favorites." }); } /// /// Toggles the favorite status of an asset for the user. /// [HttpPost("{symbol}")] public async Task ToggleFavorite(string symbol, CancellationToken cancellationToken) { if (!TryGetUserId(out var userId)) { return Unauthorized(new { message = "Invalid or expired user session." }); } string inputQuery = symbol.Trim(); if (string.IsNullOrWhiteSpace(inputQuery)) { return BadRequest(new { error = "Invalid asset identifier." }); } string canonicalIsin = ResolveCanonicalIsin(inputQuery); var allAssets = AssetsController.LoadAssetsFromIndexJson(); var matched = allAssets.FirstOrDefault(a => a.Isin.Equals(inputQuery, StringComparison.OrdinalIgnoreCase) || a.Name.Equals(inputQuery, StringComparison.OrdinalIgnoreCase)); string upperInput = inputQuery.ToUpperInvariant(); string upperMatchedName = matched != null ? matched.Name.ToUpperInvariant() : string.Empty; var existingMatches = await _dbContext.UserFavoriteAssets .Where(f => f.UserId == userId && ( f.Isin == canonicalIsin || f.Isin == upperInput || (upperMatchedName != "" && f.Isin == upperMatchedName) )) .ToListAsync(cancellationToken); bool isFavorite; if (existingMatches.Count > 0) { _dbContext.UserFavoriteAssets.RemoveRange(existingMatches); isFavorite = false; } else { _dbContext.UserFavoriteAssets.Add(new UserFavoriteAssetEntity { UserId = userId, Isin = canonicalIsin, CreatedAt = DateTime.UtcNow }); isFavorite = true; } await _dbContext.SaveChangesAsync(cancellationToken); string assetName = matched?.Name ?? canonicalIsin; string imageUrl = matched != null && !string.IsNullOrWhiteSpace(matched.Image) ? matched.Image : $"/api/v1/logo/{canonicalIsin}"; return Ok(new { symbol = canonicalIsin, name = assetName, isin = canonicalIsin, image = imageUrl, isFavorite = isFavorite }); } /// /// Removes an asset from the user's favorites. /// [HttpDelete("{symbol}")] public async Task RemoveFavorite(string symbol, CancellationToken cancellationToken) { if (!TryGetUserId(out var userId)) { return Unauthorized(new { message = "Invalid or expired user session." }); } string inputQuery = symbol.Trim(); if (string.IsNullOrWhiteSpace(inputQuery)) { return BadRequest(new { error = "Invalid asset identifier." }); } string canonicalIsin = ResolveCanonicalIsin(inputQuery); var existingMatches = await _dbContext.UserFavoriteAssets .Where(f => f.UserId == userId && (f.Isin == canonicalIsin || f.Isin == inputQuery.ToUpperInvariant())) .ToListAsync(cancellationToken); if (existingMatches.Count > 0) { _dbContext.UserFavoriteAssets.RemoveRange(existingMatches); await _dbContext.SaveChangesAsync(cancellationToken); } return Ok(new { message = $"Removed {canonicalIsin} from favorites." }); } private async Task<(double Price, double ChangePercent)?> FetchLivePriceAsync(string querySymbol) { try { if (!_mqttClient.IsConnected) return null; var livePriceDto = await _mqttClient.SendRpcRequestAsync( "tr_GetLivePrice", new IsinRequest(querySymbol), TimeSpan.FromSeconds(1.5) ); if (livePriceDto != null) { return ((double)livePriceDto.CurrentPrice, (double)livePriceDto.DailyChangePercent); } // Fallback auf TA Analysis var taResult = await _mqttClient.SendRpcRequestAsync( "ta_GetAnalysis", new IsinRequest(querySymbol), TimeSpan.FromSeconds(2) ); if (taResult?.Candles != null && taResult.Candles.Count > 0) { var last = taResult.Candles.Last(); var first = taResult.Candles.First(); double price = (double)Math.Round(last.Close, 2); double change = first.Open > 0 ? (double)Math.Round(((last.Close - first.Open) / first.Open) * 100m, 2) : 0.0; return (price, change); } } catch { } return null; } private async Task FetchAssetDetailsAsync(string cleanIsin, string? selectedTicker) { try { if (!_mqttClient.IsConnected) return null; var rpcAssets = await _mqttClient.SendRpcRequestAsync, GetValidAssetRequest>( "assets_Get", new GetValidAssetRequest(cleanIsin), TimeSpan.FromSeconds(2.5) ); if (rpcAssets != null && rpcAssets.Count > 0) { var primary = rpcAssets.First(); string isinCode = primary.Isin; string symbolCode = !string.IsNullOrWhiteSpace(selectedTicker) ? selectedTicker : isinCode; string name = string.IsNullOrWhiteSpace(primary.Name) ? isinCode : primary.Name; string image = !string.IsNullOrWhiteSpace(primary.ImageId) ? primary.ImageId : $"/api/v1/logo/{isinCode}"; return new FavoriteAssetDto(symbolCode, name, isinCode, image); } } catch { } return null; } private static string ResolveCanonicalIsin(string inputQuery) { var allAssets = AssetsController.LoadAssetsFromIndexJson(); var matched = allAssets.FirstOrDefault(a => a.Isin.Equals(inputQuery, StringComparison.OrdinalIgnoreCase) || a.Name.Equals(inputQuery, StringComparison.OrdinalIgnoreCase)); return matched != null ? matched.Isin.ToUpperInvariant() : inputQuery.ToUpperInvariant(); } private bool TryGetUserId(out Guid userId) { var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); return Guid.TryParse(userIdStr, out userId) && userId != Guid.Empty; } }