feat(Backend): update API gateway and websocket hubs

This commit is contained in:
2026-08-09 21:32:52 +02:00
parent fdf4b6efcb
commit a9553e9fbf
66 changed files with 188878 additions and 0 deletions
@@ -0,0 +1,288 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
/// <summary>
/// Represents a single configuration item for a microservice.
/// </summary>
public record ServiceConfigItem(
string Key,
string Value,
string DataType,
string Description,
DateTime UpdatedAt
);
/// <summary>
/// DTO for returning service configuration items (AOT-compliant).
/// </summary>
public record ServiceConfigItemResponseDto(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("value")] string Value,
[property: JsonPropertyName("dataType")] string DataType,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("updatedAt")] DateTime UpdatedAt
);
/// <summary>
/// DTO representing the operational health status of a microservice (AOT-compliant).
/// </summary>
public record ServiceHealthStatusDto(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("port")] string Port,
[property: JsonPropertyName("communication")] string Communication,
[property: JsonPropertyName("db")] string Db,
[property: JsonPropertyName("lastPing")] DateTime LastPing
);
[ApiController]
[Route("api/v1/admin/settings")]
[Authorize(Roles = "Admin")]
[EnableCors("AllowAll")]
public class AdminSettingsController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly ILogger<AdminSettingsController> _logger;
// In-memory static store for default UI configuration templates
private static readonly ConcurrentDictionary<string, List<ServiceConfigItem>> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
static AdminSettingsController()
{
// Default-Templates für Microservice-Konfigurationen initialisieren
_inMemorySettings["FinlyticAnalyzer"] = new List<ServiceConfigItem>
{
new("MinSignalScore", "75.0", "double", "Mindest-Score für KI-Trade-Proposals (0-100)", DateTime.UtcNow),
new("VixPanicThreshold", "30.0", "double", "VIX-Wert ab dem Panik-Modus aktiviert wird", DateTime.UtcNow),
new("ProposalTtlMinutes", "180", "int", "Gültigkeitsdauer von Trade-Proposals in Minuten", DateTime.UtcNow)
};
_inMemorySettings["FinlyticNews"] = new List<ServiceConfigItem>
{
new("ScrapeIntervalMinutes", "15", "int", "Intervall für das Scraping neuer Nachrichten", DateTime.UtcNow),
new("FinBertBatchSize", "8", "int", "Batch-Größe für die Sentiment-Analyse", DateTime.UtcNow)
};
_inMemorySettings["FinlyticTechnicalAnalysis"] = new List<ServiceConfigItem>
{
new("EmaShortPeriod", "20", "int", "Kurze Periode für EMA-Berechnungen", DateTime.UtcNow),
new("EmaLongPeriod", "50", "int", "Lange Periode für EMA-Berechnungen", DateTime.UtcNow),
new("RsiPeriod", "14", "int", "Standard-Periode für RSI-Berechnung", DateTime.UtcNow)
};
_inMemorySettings["FinlyticTrades"] = new List<ServiceConfigItem>
{
new("ExportFeedbackIntervalHours", "6", "int", "Intervall für den Parquet/JSON Feedback-Export", DateTime.UtcNow),
new("DefaultLeverageLimit", "10", "decimal", "Standardmäßiger Maximalhebel für Derivate", DateTime.UtcNow)
};
}
public AdminSettingsController(
WebMqttClient mqttClient,
ILogger<AdminSettingsController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Retrieves all service configurations grouped by service name.
/// </summary>
[HttpGet]
public IActionResult GetAllSettings()
{
var grouped = _inMemorySettings.ToDictionary(
g => g.Key,
g => g.Value.Select(item => new ServiceConfigItemResponseDto(
Key: item.Key,
Value: item.Value,
DataType: item.DataType,
Description: item.Description,
UpdatedAt: item.UpdatedAt
)).ToList()
);
return Ok(grouped);
}
/// <summary>
/// Performs live MQTT RPC health pings across all microservices and returns their real operational status.
/// </summary>
[HttpGet("health")]
public async Task<IActionResult> GetServicesHealth()
{
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 (Exception ex)
{
_logger.LogWarning(ex, "[AdminSettings] Health ping timed out or failed for service {ServiceName}", s.Name);
}
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 Ok(results);
}
/// <summary>
/// Retrieves settings for a specific service.
/// </summary>
[HttpGet("{serviceName}")]
public IActionResult GetServiceSettings(string serviceName)
{
if (_inMemorySettings.TryGetValue(serviceName, out var list))
{
var dtos = list.Select(item => new ServiceConfigItemResponseDto(
Key: item.Key,
Value: item.Value,
DataType: item.DataType,
Description: item.Description,
UpdatedAt: item.UpdatedAt
)).ToList();
return Ok(dtos);
}
return Ok(new List<ServiceConfigItemResponseDto>());
}
/// <summary>
/// Broadcasts configuration settings to the targeted microservice via MQTT.
/// Does NOT write to Backend database. The microservice persists updated settings directly into its own database.
/// </summary>
[HttpPut("{serviceName}")]
public async Task<IActionResult> UpdateServiceSettings(string serviceName, [FromBody] Dictionary<string, string> updatedValues)
{
if (updatedValues == null || !updatedValues.Any())
{
return BadRequest(new { message = "No settings provided for update." });
}
_logger.LogInformation("[AdminSettings] Transmitting {Count} config settings to microservice '{ServiceName}' via MQTT", updatedValues.Count, serviceName);
// In-Memory Template-Store aktualisieren
if (_inMemorySettings.TryGetValue(serviceName, out var existingList))
{
foreach (var (key, value) in updatedValues)
{
var idx = existingList.FindIndex(item => item.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (idx >= 0)
{
var old = existingList[idx];
existingList[idx] = old with { Value = value, UpdatedAt = DateTime.UtcNow };
}
else
{
existingList.Add(new ServiceConfigItem(key, value, "string", $"Setting for {serviceName}", DateTime.UtcNow));
}
}
}
else
{
var newList = updatedValues.Select(kv => new ServiceConfigItem(kv.Key, kv.Value, "string", $"Setting for {serviceName}", DateTime.UtcNow)).ToList();
_inMemorySettings[serviceName] = newList;
}
// MQTT Config-Update Event senden
bool mqttPublished = false;
try
{
if (_mqttClient.IsConnected)
{
string topic = $"services/config/updated/{serviceName}";
var payload = new ServiceConfigUpdatePayload(
ServiceName: serviceName,
Timestamp: DateTime.UtcNow,
Settings: updatedValues
);
await _mqttClient.PublishAsync(topic, payload);
mqttPublished = true;
_logger.LogInformation("[AdminSettings] Broadcasted config update event to MQTT topic '{Topic}' for microservice persistence.", topic);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[AdminSettings] Failed to publish MQTT config update event for service '{ServiceName}'", serviceName);
}
return Ok(new
{
message = $"Settings successfully transmitted to microservice {serviceName}.",
mqttDispatched = mqttPublished
});
}
}
@@ -0,0 +1,142 @@
using System;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticBackend.Services;
using FinlyticCore.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace FinlyticBackend.Controllers;
/// <summary>
/// DTO for admin password reset requests (AOT-compliant).
/// </summary>
public record ResetPasswordAdminDto(
[property: JsonPropertyName("newPassword")] string NewPassword
);
[ApiController]
[Route("api/v1/admin")]
[Authorize(Roles = "Admin")]
[EnableCors("AllowAll")]
public class AdminUserController : ControllerBase
{
private readonly IUserService _userService;
public AdminUserController(IUserService userService)
{
_userService = userService;
}
/// <summary>
/// Creates a new user account by an administrator.
/// </summary>
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequestDto request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
{
return BadRequest(new { error = "Email and Password are required." });
}
var createdUser = await _userService.CreateUserByAdminAsync(request, cancellationToken);
if (createdUser == null)
{
return Conflict(new { error = $"User with email '{request.Email}' already exists." });
}
return StatusCode(201, createdUser);
}
/// <summary>
/// Retrieves a list of all registered users.
/// </summary>
[HttpGet("users")]
public async Task<IActionResult> GetAllUsers(CancellationToken cancellationToken)
{
var users = await _userService.GetAllUsersAsync(cancellationToken);
return Ok(users);
}
/// <summary>
/// Updates an existing user account.
/// </summary>
[HttpPut("users/{id:guid}")]
public async Task<IActionResult> UpdateUser(Guid id, [FromBody] UpdateUserRequestDto request, CancellationToken cancellationToken)
{
var currentAdminId = GetCurrentUserId();
// Schutz: Ein Admin sollte sich nicht selbst abwerten oder deaktivieren können
if (currentAdminId.HasValue && currentAdminId.Value == id)
{
if (request.IsActive == false)
{
return BadRequest(new { error = "You cannot deactivate your own admin account." });
}
if (!string.IsNullOrWhiteSpace(request.Role) && !request.Role.Equals("Admin", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new { error = "You cannot revoke your own Admin role." });
}
}
var updated = await _userService.UpdateUserAsync(id, request, cancellationToken);
if (updated == null)
{
return NotFound(new { error = $"User with ID '{id}' not found." });
}
return Ok(updated);
}
/// <summary>
/// Deactivates a user account.
/// </summary>
[HttpDelete("users/{id:guid}")]
public async Task<IActionResult> DeactivateUser(Guid id, CancellationToken cancellationToken)
{
var currentAdminId = GetCurrentUserId();
if (currentAdminId.HasValue && currentAdminId.Value == id)
{
return BadRequest(new { error = "You cannot deactivate your own admin account." });
}
bool deactivated = await _userService.DeactivateUserAsync(id, cancellationToken);
if (!deactivated)
{
return NotFound(new { error = $"User with ID '{id}' not found." });
}
return Ok(new { message = $"User '{id}' deactivated successfully." });
}
/// <summary>
/// Resets a user's password and forces password change on next login.
/// </summary>
[HttpPost("users/{id:guid}/reset-password")]
public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordAdminDto request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request?.NewPassword))
{
return BadRequest(new { error = "NewPassword is required." });
}
bool reset = await _userService.ResetPasswordAsync(id, request.NewPassword, cancellationToken);
if (!reset)
{
return NotFound(new { error = $"User with ID '{id}' not found." });
}
return Ok(new { message = $"Password for user '{id}' has been reset successfully. They must change it upon next login." });
}
private Guid? GetCurrentUserId()
{
var claimVal = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
return Guid.TryParse(claimVal, out var parsed) ? parsed : null;
}
}
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
/// <summary>
/// Request payload for triggering manual analysis (AOT-compliant).
/// </summary>
public record AnalyzeRequest(
[property: JsonPropertyName("symbol")] string? Symbol,
[property: JsonPropertyName("isin")] string? Isin,
[property: JsonPropertyName("sector")] string? Sector,
[property: JsonPropertyName("headline")] string? Headline,
[property: JsonPropertyName("currentPrice")] decimal? CurrentPrice,
[property: JsonPropertyName("riskScore")] int? RiskScore,
[property: JsonPropertyName("minTimeframeValue")] int? MinTimeframeValue,
[property: JsonPropertyName("maxTimeframeValue")] int? MaxTimeframeValue,
[property: JsonPropertyName("timeframeUnit")] string? TimeframeUnit,
[property: JsonPropertyName("instrumentType")] string? InstrumentType,
[property: JsonPropertyName("userNotes")] string? UserNotes
);
[ApiController]
[Authorize]
[Route("api/v1/analyze")]
[EnableCors("AllowAll")]
public class AnalyzeController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly ILogger<AnalyzeController> _logger;
public AnalyzeController(WebMqttClient mqttClient, ILogger<AnalyzeController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Triggers a manual analysis for an asset by gathering context in parallel and dispatching to FinlyticAnalyzer.
/// </summary>
[HttpPost("manual")]
public async Task<IActionResult> TriggerManualAnalysis([FromBody] AnalyzeRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
{
return BadRequest(new { error = "ISIN or Symbol is required" });
}
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "Analysis service is currently unavailable." });
}
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
string targetSymbol = (request.Symbol ?? request.Isin ?? string.Empty).Trim().ToUpperInvariant();
var isinReq = new IsinRequest(targetIsin);
// 1. Parallelisiertes Context-Gathering (TA, Fundamentals, Sentiment) für minimale Latenz
var taTask = FetchTaDataAsync(isinReq);
var fundTask = FetchFundamentalsDataAsync(isinReq);
var sentTask = FetchSentimentDataAsync(isinReq);
await Task.WhenAll(taTask, fundTask, sentTask);
var taData = await taTask;
var fundData = await fundTask;
var sentData = await sentTask;
decimal lastCandlePrice = taData?.Candles != null && taData.Candles.Count > 0 ? (decimal)taData.Candles.Last().Close : 0m;
decimal resolvedPrice = request.CurrentPrice ??
(lastCandlePrice > 0 ? lastCandlePrice :
(fundData != null && fundData.CurrentPrice > 0 ? fundData.CurrentPrice : 100.0m));
var rpcRequest = new ManualAnalysisRpcRequest(
Isin: targetIsin,
Symbol: targetSymbol,
Sector: !string.IsNullOrWhiteSpace(request.Sector) ? request.Sector : "Technology",
Headline: !string.IsNullOrWhiteSpace(request.Headline) ? request.Headline : "Manual Analysis Triggered by User",
CurrentPrice: resolvedPrice,
RiskScore: request.RiskScore ?? 50,
MinTimeframeValue: request.MinTimeframeValue ?? 4,
MaxTimeframeValue: request.MaxTimeframeValue ?? 6,
TimeframeUnit: !string.IsNullOrWhiteSpace(request.TimeframeUnit) ? request.TimeframeUnit : "Tage",
InstrumentType: !string.IsNullOrWhiteSpace(request.InstrumentType) ? request.InstrumentType : "Stock",
UserNotes: request.UserNotes ?? string.Empty,
TaData: taData,
FundamentalsData: fundData,
SentimentData: sentData
);
// 2. Ausführen des RPC Triggers am FinlyticAnalyzer (mit typisierter Response)
try
{
var response = await _mqttClient.SendRpcRequestAsync<ManualAnalysisResponseDto, ManualAnalysisRpcRequest>(
"analyzer_TriggerManual", rpcRequest, TimeSpan.FromSeconds(10));
if (response != null)
{
return Ok(response);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[AnalyzeController] RPC analyzer_TriggerManual timed out or failed for ISIN '{Isin}'.", targetIsin);
}
return Ok(new
{
status = "AnalysisTriggered",
isin = rpcRequest.Isin,
message = "Manuelle KI-Analyse wurde gestartet, verarbeitet Ergebnisse im Hintergrund."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger manual analysis via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error triggering analysis." });
}
}
/// <summary>
/// Fetches the currently active trade proposals from the FinlyticTrades service.
/// </summary>
[HttpGet("proposals")]
public async Task<IActionResult> GetActiveProposals()
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "Analysis service is currently unavailable." });
}
// AOT-sicherer RPC-Aufruf an FinlyticTrades für vorgeschlagene Trades
var request = new GetTradesRequest(Isin: null, Status: "Proposed", UserId: null);
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get", request, TimeSpan.FromSeconds(4));
return Ok(proposals ?? new List<TradeProposalDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch active proposals via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error fetching proposals." });
}
}
private async Task<TechnicalAnalysisDto?> FetchTaDataAsync(IsinRequest request)
{
try
{
return await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
"ta_GetAnalysis", request, TimeSpan.FromSeconds(2));
}
catch { return null; }
}
private async Task<AssetFundamentalsDto?> FetchFundamentalsDataAsync(IsinRequest request)
{
try
{
return await _mqttClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
"fundamentals_Get", request, TimeSpan.FromSeconds(2));
}
catch { return null; }
}
private async Task<IsinSentimentSummaryDto?> FetchSentimentDataAsync(IsinRequest request)
{
try
{
return await _mqttClient.SendRpcRequestAsync<IsinSentimentSummaryDto, IsinRequest>(
"sentiment_GetIsin", request, TimeSpan.FromSeconds(2));
}
catch { return null; }
}
}
@@ -0,0 +1,298 @@
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;
/// <summary>
/// DTO for asset search results (AOT-compliant).
/// </summary>
public record AssetSearchResultDto(
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("image")] string Image
);
/// <summary>
/// DTO for discovery assets (AOT-compliant).
/// </summary>
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<string> Tags
);
[ApiController]
[Authorize]
[Route("api/v1/assets")]
public class AssetsController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly BackendDbContext _dbContext;
private readonly ILogger<AssetsController> _logger;
private static List<AssetIndex>? _cachedIndexAssets = null;
private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes("""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="30" fill="#1E293B"/>
<path d="M 30 65 L 45 45 L 60 55 L 75 35" fill="none" stroke="#10B981" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="75" cy="35" r="5" fill="#06B6D4"/>
</svg>
""");
public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger<AssetsController> logger)
{
_mqttClient = mqttClient;
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Lädt den AssetIndex direkt aus der gemounteten index.json.
/// </summary>
public static List<AssetIndex> 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<List<AssetIndex>>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex);
if (assets != null && assets.Count > 0)
{
_cachedIndexAssets = assets;
return assets;
}
}
catch { }
}
return [];
}
/// <summary>
/// Durchsucht Assets im Index.
/// </summary>
[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);
}
/// <summary>
/// Discovery-Endpunkt via RPC mit Fallback auf die index.json.
/// </summary>
[HttpGet("discovery")]
public async Task<IActionResult> GetDiscoveryAssets([FromQuery] int limit = 15)
{
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, 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<string>()
)).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<string>()
)).ToList();
return Ok(fallback);
}
/// <summary>
/// Liest Fundamentaldaten per ISIN.
/// </summary>
[HttpGet("{isin}/fundamentals")]
public async Task<IActionResult> 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<AssetFundamentalsDto, IsinRequest>(
"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." });
}
/// <summary>
/// Liest Technische Analyse per ISIN.
/// </summary>
[HttpGet("{isin}/technicals")]
public async Task<IActionResult> 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<TechnicalAnalysisDto, IsinRequest>(
"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." });
}
/// <summary>
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
/// </summary>
[HttpGet("logo/{isin}")]
public async Task<IActionResult> 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");
}
}
@@ -0,0 +1,143 @@
using System;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticBackend.Services;
using FinlyticCore.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace FinlyticBackend.Controllers;
/// <summary>
/// DTO representing the current user's profile (AOT-compliant).
/// </summary>
public record UserProfileResponseDto(
[property: JsonPropertyName("userId")] string UserId,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("fullName")] string FullName,
[property: JsonPropertyName("role")] string Role
);
public record ChangeInitialPasswordDto(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("newPassword")] string NewPassword
);
public class ForgotPasswordRequestDto
{
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
}
[ApiController]
[Route("api/v1")]
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
public AuthController(IUserService userService)
{
_userService = userService;
}
/// <summary>
/// Authenticates a user and returns a JWT token.
/// </summary>
[HttpPost("auth/login")]
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
{
return BadRequest(new { error = "Email and Password are required." });
}
var authResult = await _userService.AuthenticateAsync(request, cancellationToken);
if (authResult == null)
{
return Unauthorized(new { error = "Invalid credentials or user account is inactive." });
}
return Ok(authResult);
}
/// <summary>
/// Changes the initial password required by an admin reset or creation.
/// </summary>
[HttpPost("auth/change-initial-password")]
public async Task<IActionResult> ChangeInitialPassword([FromBody] ChangeInitialPasswordDto request, CancellationToken cancellationToken)
{
if (request.UserId == Guid.Empty || string.IsNullOrWhiteSpace(request.NewPassword))
{
return BadRequest(new { error = "UserId and NewPassword are required." });
}
bool changed = await _userService.ChangeInitialPasswordAsync(request.UserId, request.NewPassword, cancellationToken);
if (!changed)
{
return BadRequest(new { error = "Password change failed. User not found or password change not required." });
}
return Ok(new { message = "Password changed successfully. You may now login." });
}
/// <summary>
/// Updates the user's FCM device token.
/// </summary>
[Authorize]
[HttpPost("user/fcm-token")]
public async Task<IActionResult> UpdateFcmToken([FromBody] UpdateFcmTokenRequestDto request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request?.FcmToken))
{
return BadRequest(new { error = "FcmToken is required." });
}
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value;
if (!Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized(new { error = "Invalid User Token claim." });
}
bool success = await _userService.RegisterOrUpdateFcmTokenAsync(userId, request.FcmToken, request.DeviceName ?? "Unknown Device", cancellationToken);
if (!success)
{
return BadRequest(new { error = "Failed to update FCM device token." });
}
return Ok(new { message = "FCM device token registered successfully." });
}
/// <summary>
/// Gets the current user's profile details.
/// </summary>
[Authorize]
[HttpGet("user/me")]
public IActionResult GetCurrentUserProfile()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value
?? string.Empty;
var email = User.FindFirst(ClaimTypes.Email)?.Value
?? User.FindFirst("email")?.Value
?? string.Empty;
var name = User.FindFirst(ClaimTypes.Name)?.Value
?? User.FindFirst("name")?.Value
?? string.Empty;
var role = User.FindFirst(ClaimTypes.Role)?.Value
?? User.FindFirst("role")?.Value
?? "User";
return Ok(new UserProfileResponseDto(
UserId: userId,
Email: email,
FullName: name,
Role: role
));
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
/// <summary>
/// Response DTO for corporate calendar events (AOT-compliant).
/// </summary>
public record CalendarEventResponseDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("companyName")]
string CompanyName,
[property: JsonPropertyName("eventType")]
string EventType,
[property: JsonPropertyName("eventDate")]
DateTime EventDate,
[property: JsonPropertyName("date")] string Date,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("ticker")] string Ticker,
[property: JsonPropertyName("description")]
string Description,
[property: JsonPropertyName("details")]
string Details,
[property: JsonPropertyName("image")] string Image
);
[ApiController]
[Authorize]
[Route("api/v1/calendar")]
public class CalendarController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly ILogger<CalendarController> _logger;
public CalendarController(WebMqttClient mqttClient, ILogger<CalendarController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Retrieves corporate calendar events with optional filters.
/// </summary>
[HttpGet("events")]
public async Task<IActionResult> GetCorporateCalendar(
[FromQuery] string? category = null,
[FromQuery] DateTime? date = null,
[FromQuery] string? symbol = null,
[FromQuery] string? isin = null)
{
string? activeSymbol = !string.IsNullOrWhiteSpace(symbol) ? symbol.Trim() : isin?.Trim();
try
{
if (_mqttClient.IsConnected)
{
var rawEvents = await _mqttClient.SendRpcRequestAsync<List<CorporateEventDto>, EmptyRequest>(
"events_GetAll",
new EmptyRequest(),
TimeSpan.FromSeconds(4)
);
if (rawEvents != null && rawEvents.Count > 0)
{
var allAssets = AssetsController.LoadAssetsFromIndexJson() ?? new List<AssetIndex>();
var isinLookup = allAssets
.Where(a => !string.IsNullOrEmpty(a.Isin))
.GroupBy(a => a.Isin, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var parsedEvents = rawEvents.Select(e =>
{
string eventIsin = e.Isin ?? string.Empty;
string ticker = e.Ticker ?? string.Empty;
string companyName = e.CompanyName ?? string.Empty;
string eventType = e.EventType ?? string.Empty;
DateTime eventDate = e.Date;
string image = $"/api/v1/logo/{eventIsin}";
if (!string.IsNullOrEmpty(eventIsin) && isinLookup.TryGetValue(eventIsin, out var matchedAsset))
{
if (!string.IsNullOrWhiteSpace(matchedAsset.Name))
{
companyName = matchedAsset.Name;
}
if (!string.IsNullOrWhiteSpace(matchedAsset.Image))
{
image = matchedAsset.Image;
}
}
return new CalendarEventResponseDto(
Id: $"evt-{eventIsin}-{eventDate:yyyyMMdd}-{eventType}",
Symbol: string.IsNullOrWhiteSpace(ticker) ? eventIsin : ticker,
CompanyName: companyName,
EventType: eventType,
EventDate: eventDate,
Date: eventDate.ToString("o"),
Isin: eventIsin,
Ticker: ticker,
Description: $"{eventType} - {companyName}",
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
Image: image
);
});
// Optionales Filtern
var filtered = parsedEvents;
if (!string.IsNullOrWhiteSpace(category) &&
!string.Equals(category, "Alle", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(category, "all", StringComparison.OrdinalIgnoreCase))
{
filtered = filtered.Where(e =>
string.Equals(e.EventType, category, StringComparison.OrdinalIgnoreCase));
}
if (date.HasValue)
{
filtered = filtered.Where(e => e.EventDate.Date == date.Value.Date);
}
if (!string.IsNullOrWhiteSpace(activeSymbol))
{
filtered = filtered.Where(e =>
string.Equals(e.Isin, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
string.Equals(e.Ticker, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
string.Equals(e.Symbol, activeSymbol, StringComparison.OrdinalIgnoreCase)
);
}
return Ok(filtered.OrderBy(e => e.EventDate).ToList());
}
}
else
{
_logger.LogWarning("[CalendarController] MQTT client is NOT connected to broker.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[CalendarController] Failed to retrieve corporate events via MQTT RPC.");
}
return Ok(new List<CalendarEventResponseDto>());
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace FinlyticBackend.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
[Authorize(Roles = "Admin")]
public class DashboardController : ControllerBase
{
/// <summary>
/// Retrieves global statistics aggregated from all microservices.
/// Currently returns mocked data for UI development.
/// </summary>
[HttpGet("stats")]
public IActionResult GetGlobalStats()
{
return Ok(new
{
totalAssetsLoaded = 12450,
totalNewsArticles = 8340,
activeTrades = 12,
systemUptimeHours = 342,
sentimentAnalysesCompleted = 45000,
technicalAnalysesCompleted = 120000
});
}
}
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Sentiment;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/news")]
public class NewsController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly ILogger<NewsController> _logger;
public NewsController(WebMqttClient mqttClient, ILogger<NewsController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Retrieves paginated news articles with optional filters and enriched sentiment data.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetNews(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? symbol = null,
[FromQuery] string? isin = null,
[FromQuery] string? date = null,
[FromQuery] string? status = null,
[FromQuery] string? query = null,
[FromQuery] bool? hasSentiment = null)
{
string? activeSymbol = !string.IsNullOrWhiteSpace(symbol) ? symbol.Trim() : isin?.Trim();
string? effectiveStatus = status;
if (hasSentiment == true && string.IsNullOrEmpty(effectiveStatus))
{
effectiveStatus = "Analyzed";
}
string? dateStr = !string.IsNullOrWhiteSpace(date) ? date.Trim() : null;
if (string.Equals(dateStr, "today", StringComparison.OrdinalIgnoreCase))
{
dateStr = DateTime.UtcNow.ToString("yyyy-MM-dd");
}
try
{
if (_mqttClient.IsConnected)
{
var payload = new DailyNewsRequest(
Limit: pageSize,
Offset: (page - 1) * pageSize,
Isin: activeSymbol,
Date: dateStr,
Status: effectiveStatus,
Query: query,
HasSentiment: hasSentiment
);
var articles = await _mqttClient.SendRpcRequestAsync<List<NewsArticleDto>, DailyNewsRequest>(
"news_Get",
payload,
TimeSpan.FromSeconds(10)
);
if (articles != null && articles.Count > 0)
{
// Parallelisierte Anreichung fehlender Sentiment-Daten
if (hasSentiment == true || string.Equals(effectiveStatus, "Analyzed", StringComparison.OrdinalIgnoreCase))
{
var enrichmentTasks = articles.Select(async article =>
{
if (article.FinbertResult != null)
{
return article;
}
try
{
var sentimentEntry = await _mqttClient.SendRpcRequestAsync<IsinAnalysisEntry, ArticleRequest>(
"sentiment_GetArticle",
new ArticleRequest(article.Id.ToString(), article.Id.ToString()),
TimeSpan.FromSeconds(2)
);
if (sentimentEntry?.FinbertResult != null)
{
var result = sentimentEntry.FinbertResult;
return article with
{
Sentiment = result.Label,
SentimentScore = result.CompoundScore,
Confidence = result.Confidence,
FinbertResult = result
};
}
}
catch
{
// RPC Timeout oder nicht analysiert -> Un-angereicherten Artikel zurückgeben
}
return article;
});
var enrichedArticles = await Task.WhenAll(enrichmentTasks);
return Ok(enrichedArticles);
}
return Ok(articles);
}
}
else
{
_logger.LogWarning("[GetNews] Cannot send MQTT RPC - MQTT client is NOT connected to broker.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[GetNews] MQTT RPC news_Get request failed for symbol '{Symbol}'", activeSymbol);
}
return Ok(new List<NewsArticleDto>());
}
/// <summary>
/// Retrieves sentiment summary for a specific ISIN.
/// </summary>
[HttpGet("sentiment/isin/{isin}")]
public async Task<IActionResult> GetIsinSentimentSummary(string isin)
{
if (string.IsNullOrWhiteSpace(isin)) return NotFound(new { message = "ISIN is required." });
var isinCode = isin.Trim().ToUpperInvariant();
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync<IsinSentimentSummaryDto, IsinRequest>(
"sentiment_GetIsin",
new IsinRequest(isinCode),
TimeSpan.FromSeconds(5)
);
if (rpcResult != null)
{
return Ok(rpcResult);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT RPC sentiment_GetIsin failed for ISIN '{Isin}'", isinCode);
}
// File-System Fallback
var candidates = new[]
{
Path.Combine(Directory.GetCurrentDirectory(), "data", "summaries", "isin", $"{isinCode}.json"),
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "summaries", "isin", $"{isinCode}.json"),
Path.Combine(Directory.GetCurrentDirectory(), "..", "data", "summaries", "isin", $"{isinCode}.json")
};
foreach (var path in candidates)
{
if (System.IO.File.Exists(path))
{
try
{
var json = await System.IO.File.ReadAllTextAsync(path);
return Content(json, "application/json");
}
catch { }
}
}
return NotFound(new { message = $"No sentiment summary found for ISIN {isinCode}." });
}
/// <summary>
/// Retrieves sentiment analysis for a specific article.
/// </summary>
[HttpGet("sentiment/article/{articleId}")]
public async Task<IActionResult> GetArticleSentimentAnalysis(string articleId)
{
if (string.IsNullOrWhiteSpace(articleId)) return NotFound(new { message = "ArticleId is required." });
var targetId = articleId.Trim();
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync<IsinAnalysisEntry, ArticleRequest>(
"sentiment_GetArticle",
new ArticleRequest(targetId, targetId),
TimeSpan.FromSeconds(5)
);
if (rpcResult != null)
{
return Ok(rpcResult);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT RPC sentiment_GetArticle failed for articleId '{ArticleId}'", targetId);
}
// File-System Fallback
var articleCandidates = new[]
{
Path.Combine(Directory.GetCurrentDirectory(), "data", "summaries", "articles", $"{targetId}.json"),
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "summaries", "articles", $"{targetId}.json")
};
foreach (var path in articleCandidates)
{
if (System.IO.File.Exists(path))
{
try
{
var json = await System.IO.File.ReadAllTextAsync(path);
return Content(json, "application/json");
}
catch { }
}
}
return NotFound(new { message = $"No sentiment analysis found for article {articleId}." });
}
}
@@ -0,0 +1,385 @@
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;
}
}
@@ -0,0 +1,102 @@
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using FinlyticBackend.Database;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FinlyticBackend.Controllers;
[ApiController]
[Route("api/v1/user/preferences")]
[Authorize]
public class UserPreferencesController : ControllerBase
{
private readonly BackendDbContext _context;
public UserPreferencesController(BackendDbContext context)
{
_context = context;
}
/// <summary>
/// Request payload for updating the user's theme.
/// Supported IDs for FluentAvalonia: fluent_dark, fluent_light, fluent_accent, dark_classic.
/// </summary>
public record UpdateThemeRequest(string ThemeId);
/// <summary>
/// Gets current user preferences including FluentAvalonia theme preference.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetPreferences()
{
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (!Guid.TryParse(userIdStr, out var userId))
{
return Unauthorized(new { message = "Invalid user claims." });
}
var user = await _context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
{
return NotFound(new { message = "User not found." });
}
// Default logic for FluentAvaloniaTheme integration
string theme = string.IsNullOrWhiteSpace(user.ThemePreference) ? "fluent_dark" : user.ThemePreference;
return Ok(new
{
userId = user.Id,
email = user.Email,
fullName = user.FullName,
role = user.Role,
themePreference = theme
});
}
/// <summary>
/// Updates current user theme preference in PostgreSQL database.
/// </summary>
[HttpPut("theme")]
public async Task<IActionResult> UpdateThemePreference([FromBody] UpdateThemeRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.ThemeId))
{
return BadRequest(new { message = "ThemeId is required." });
}
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (!Guid.TryParse(userIdStr, out var userId))
{
return Unauthorized(new { message = "Invalid user claims." });
}
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
{
return NotFound(new { message = "User not found." });
}
string cleanTheme = request.ThemeId.Trim().ToLowerInvariant();
// Normalize theme names for FluentAvaloniaTheme support
user.ThemePreference = cleanTheme switch
{
"dark" => "fluent_dark",
"light" => "fluent_light",
_ => cleanTheme
};
await _context.SaveChangesAsync();
return Ok(new
{
message = "Theme preference updated successfully.",
themePreference = user.ThemePreference
});
}
}
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Models.Trades;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/user/trades")]
public class UserTradesController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly ILogger<UserTradesController> _logger;
public UserTradesController(WebMqttClient mqttClient, ILogger<UserTradesController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Liest die eindeutige UserId aus den Claims des authentifizierten Bearer Tokens.
/// </summary>
private string GetUserIdFromClaims()
{
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")
?? User.FindFirstValue("nameid");
if (!string.IsNullOrWhiteSpace(claimUserId))
{
return claimUserId;
}
_logger.LogWarning("[UserTradesController] Claim NameIdentifier not found for authenticated request. Falling back to default_user.");
return "default_user";
}
/// <summary>
/// Retrieves a list of trades for the current authenticated user (including global proposals).
/// </summary>
[HttpGet]
public async Task<IActionResult> GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
var userId = GetUserIdFromClaims();
// FIX: UserId explizit an GetTradesRequest übergeben!
var request = new GetTradesRequest(isin, status, userId);
var trades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get",
request,
TimeSpan.FromSeconds(5));
return Ok(trades ?? new List<TradeProposalDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve user trades via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error while fetching trades" });
}
}
/// <summary>
/// Public endpoint to retrieve active global proposals for guest users.
/// </summary>
[HttpGet("/api/v1/trades/public")]
[AllowAnonymous]
public async Task<IActionResult> GetPublicProposals([FromQuery] string? isin = null)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
// Status = Proposed für anonyme Öffentliche Anfragen
var request = new GetTradesRequest(isin, "Proposed", UserId: null);
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get",
request,
TimeSpan.FromSeconds(5));
return Ok(proposals ?? new List<TradeProposalDto>());
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to retrieve public trade proposals via MQTT RPC.");
return Ok(new List<TradeProposalDto>());
}
}
/// <summary>
/// Accepts a proposed trade and assigns it to the current user's portfolio.
/// </summary>
[HttpPost("accept")]
public async Task<IActionResult> AcceptTrade([FromBody] TradeAcceptanceDto request)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
if (string.IsNullOrWhiteSpace(request.AnalysisId) && string.IsNullOrWhiteSpace(request.TradeId))
{
return BadRequest(new { error = "AnalysisId or TradeId is required" });
}
// FIX: UserId felsenfest aus den authentifizierten Claims überschreiben
request.UserId = GetUserIdFromClaims();
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, TradeAcceptanceDto>(
"trades_Accept",
request,
TimeSpan.FromSeconds(5));
if (result != null)
{
return Ok(result);
}
// Fallback Fire-and-Forget
await _mqttClient.PublishAsync($"finlytic/trades/accept/{request.Isin}", request);
return Ok(new { status = "Accepted", analysisId = request.AnalysisId, tradeId = request.TradeId, userId = request.UserId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to accept trade proposal via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error" });
}
}
/// <summary>
/// Closes an active trade.
/// </summary>
[HttpPost("{id}/close")]
public async Task<IActionResult> CloseTrade(string id, [FromBody] CloseTradeRequest? request)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
var closeReq = request ?? new CloseTradeRequest { UserExitPrice = 100.0m, CloseReason = "UserManualClose" };
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
$"trades_Close/{id}",
closeReq,
TimeSpan.FromSeconds(5));
if (result != null)
{
return Ok(result);
}
return Ok(new { status = "Closed", tradeId = id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to close trade {Id} via MQTT RPC.", id);
return StatusCode(500, new { error = "Internal server error while closing trade" });
}
}
/// <summary>
/// Rejects a proposed trade.
/// </summary>
[HttpPost("{id}/reject")]
public async Task<IActionResult> RejectTrade(string id, [FromBody] CloseTradeRequest? request)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
var closeReq = request ?? new CloseTradeRequest { CloseReason = "UserRejected" };
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
$"trades_Reject/{id}",
closeReq,
TimeSpan.FromSeconds(5));
if (result != null)
{
return Ok(result);
}
return Ok(new { status = "Rejected", tradeId = id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reject trade {Id} via MQTT RPC.", id);
return StatusCode(500, new { error = "Internal server error while rejecting trade" });
}
}
}