feat(Backend): update API gateway and websocket hubs
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user