using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticAssets.Util;
using FinlyticBackend.Database;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Assets;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models.Assets;
using FinlyticCore.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
///
/// DTO for asset search results (AOT-compliant).
///
public record AssetSearchResultDto(
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("image")] string Image
);
///
/// DTO for discovery assets (AOT-compliant).
///
public record DiscoveryAssetResponseDto(
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("category")] string Category,
[property: JsonPropertyName("image")] string Image,
[property: JsonPropertyName("tags")] List Tags
);
[ApiController]
[Authorize]
[Route("api/v1/assets")]
public class AssetsController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly BackendDbContext _dbContext;
private readonly ILogger _logger;
private static List? _cachedIndexAssets = null;
private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes("""
""");
public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger logger)
{
_mqttClient = mqttClient;
_dbContext = dbContext;
_logger = logger;
}
///
/// Lädt den AssetIndex direkt aus der gemounteten index.json.
///
public static List LoadAssetsFromIndexJson()
{
if (_cachedIndexAssets != null && _cachedIndexAssets.Count > 0)
{
return _cachedIndexAssets;
}
var filePath = Path.Combine(Volumes.IndexRelativePath, "index.json");
if (System.IO.File.Exists(filePath))
{
try
{
string json = System.IO.File.ReadAllText(filePath);
var assets = JsonSerializer.Deserialize>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex);
if (assets != null && assets.Count > 0)
{
_cachedIndexAssets = assets;
return assets;
}
}
catch { }
}
return [];
}
///
/// Durchsucht Assets im Index.
///
[HttpGet("search")]
public IActionResult SearchAssets([FromQuery] string q = "")
{
string query = q?.Trim() ?? "";
var allAssets = LoadAssetsFromIndexJson();
var localMatches = !string.IsNullOrWhiteSpace(query)
? allAssets.Where(a =>
(!string.IsNullOrEmpty(a.Name) && a.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(a.Isin) && a.Isin.Contains(query, StringComparison.OrdinalIgnoreCase))
).ToList()
: allAssets.Take(50).ToList();
var results = localMatches.Select(a => new AssetSearchResultDto(
Symbol: a.Isin,
Name: a.Name,
Isin: a.Isin,
Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}"
)).Take(50).ToList();
return Ok(results);
}
///
/// Discovery-Endpunkt via RPC mit Fallback auf die index.json.
///
[HttpGet("discovery")]
public async Task GetDiscoveryAssets([FromQuery] int limit = 15)
{
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync, GetDiscoveryAssetsRequest>(
"assets_GetDiscovery",
new GetDiscoveryAssetsRequest(limit),
TimeSpan.FromSeconds(3)
);
if (rpcResult != null && rpcResult.Count > 0)
{
var formatted = rpcResult.Select(a => new DiscoveryAssetResponseDto(
Isin: a.Isin,
Symbol: a.Isin,
Name: a.Name,
Type: a.Type,
Category: a.InstrumentCategory,
Image: !string.IsNullOrWhiteSpace(a.ImageId) ? a.ImageId : $"/api/v1/assets/logo/{a.Isin}",
Tags: a.Tags?.Select(t => t.Name).ToList() ?? new List()
)).ToList();
return Ok(formatted);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[AssetsController] RPC discovery fehlgeschlagen. Fallback auf index.json.");
}
var allAssets = LoadAssetsFromIndexJson();
var fallback = allAssets.Take(limit).Select(a => new DiscoveryAssetResponseDto(
Isin: a.Isin,
Symbol: a.Isin,
Name: a.Name,
Type: "stock",
Category: "Stock",
Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}",
Tags: new List()
)).ToList();
return Ok(fallback);
}
///
/// Liest Fundamentaldaten per ISIN.
///
[HttpGet("{isin}/fundamentals")]
public async Task GetFundamentals(
[FromRoute] string isin,
[FromQuery] string? ticker = null,
[FromQuery] bool forceRefresh = false,
CancellationToken cancellationToken = default)
{
string cleanIsin = isin.Trim().ToUpperInvariant();
string? cleanTicker = ticker?.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(cleanIsin))
{
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
}
try
{
if (_mqttClient.IsConnected)
{
var requestDto = new IsinRequest(
Isin: cleanIsin,
Ticker: cleanTicker,
ForceRefresh: forceRefresh
);
var rpcResult = await _mqttClient.SendRpcRequestAsync(
"fundamentals_Get",
requestDto,
TimeSpan.FromSeconds(forceRefresh ? 15 : 8)
);
if (rpcResult != null)
{
return Ok(rpcResult);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[ApiGateway] RPC fundamentals_Get für ISIN '{Isin}' fehlgeschlagen.", cleanIsin);
}
return NotFound(new { message = $"Keine Fundamentaldaten für ISIN '{cleanIsin}' vorhanden." });
}
///
/// Liest Technische Analyse per ISIN.
///
[HttpGet("{isin}/technicals")]
public async Task GetTechnicals(
[FromRoute] string isin,
[FromQuery] bool forceRefresh = false,
[FromQuery] string? ticker = null)
{
string normalizedSymbol = isin.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(normalizedSymbol))
{
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
}
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync(
"ta_GetAnalysis",
new IsinRequest(normalizedSymbol, ticker, forceRefresh),
TimeSpan.FromSeconds(5)
);
if (rpcResult != null)
{
return Ok(rpcResult);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RPC ta_GetAnalysis für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
}
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
}
///
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
///
[HttpGet("logo/{isin}")]
public async Task GetAssetLogo([FromRoute] string isin)
{
if (string.IsNullOrWhiteSpace(isin)) return NotFound();
string cleanIsin = isin.Trim().ToUpperInvariant();
// Path Traversal Guard
string safeFileName = string.Concat(cleanIsin.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + ".svg";
string logoPath = Path.Combine(Volumes.LogosRelativePath, safeFileName);
if (System.IO.File.Exists(logoPath))
{
try
{
byte[] localData = await System.IO.File.ReadAllBytesAsync(logoPath);
return File(localData, "image/svg+xml");
}
catch { }
}
// Fallback: SVG Placeholder
return File(PlaceholderSvgBytes, "image/svg+xml");
}
}