385 lines
14 KiB
C#
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;
|
|
}
|
|
} |