Files

385 lines
14 KiB
C#

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;
/// <summary>
/// DTO representing a user's favorite asset.
/// </summary>
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<UserFavoritesController> _logger;
public UserFavoritesController(
BackendDbContext dbContext,
WebMqttClient mqttClient,
ILogger<UserFavoritesController> logger)
{
_dbContext = dbContext;
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Retrieves the user's favorite assets with parallelized RPC queries for maximum performance.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<FavoriteAssetDto>());
}
// 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<FavoriteAssetDto>();
var seenKeys = new HashSet<string>(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);
}
/// <summary>
/// Updates the selected ticker for a favorite asset.
/// </summary>
[HttpPost("{symbol}/ticker")]
public async Task<IActionResult> 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." });
}
/// <summary>
/// Toggles the favorite status of an asset for the user.
/// </summary>
[HttpPost("{symbol}")]
public async Task<IActionResult> 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
});
}
/// <summary>
/// Removes an asset from the user's favorites.
/// </summary>
[HttpDelete("{symbol}")]
public async Task<IActionResult> 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<LivePriceDto, IsinRequest>(
"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<TechnicalAnalysisDto, IsinRequest>(
"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<FavoriteAssetDto?> FetchAssetDetailsAsync(string cleanIsin, string? selectedTicker)
{
try
{
if (!_mqttClient.IsConnected) return null;
var rpcAssets = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, 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;
}
}