diff --git a/FinlyticBackend/Controllers/AdminSettingsController.cs b/FinlyticBackend/Controllers/AdminSettingsController.cs
new file mode 100644
index 0000000..88215bd
--- /dev/null
+++ b/FinlyticBackend/Controllers/AdminSettingsController.cs
@@ -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;
+
+///
+/// Represents a single configuration item for a microservice.
+///
+public record ServiceConfigItem(
+ string Key,
+ string Value,
+ string DataType,
+ string Description,
+ DateTime UpdatedAt
+);
+
+///
+/// DTO for returning service configuration items (AOT-compliant).
+///
+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
+);
+
+///
+/// DTO representing the operational health status of a microservice (AOT-compliant).
+///
+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 _logger;
+
+ // In-memory static store for default UI configuration templates
+ private static readonly ConcurrentDictionary> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
+
+ static AdminSettingsController()
+ {
+ // Default-Templates für Microservice-Konfigurationen initialisieren
+ _inMemorySettings["FinlyticAnalyzer"] = new List
+ {
+ 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
+ {
+ 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
+ {
+ 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
+ {
+ 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 logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Retrieves all service configurations grouped by service name.
+ ///
+ [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);
+ }
+
+ ///
+ /// Performs live MQTT RPC health pings across all microservices and returns their real operational status.
+ ///
+ [HttpGet("health")]
+ public async Task 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
+ {
+ 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(
+ 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);
+ }
+
+ ///
+ /// Retrieves settings for a specific service.
+ ///
+ [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());
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPut("{serviceName}")]
+ public async Task UpdateServiceSettings(string serviceName, [FromBody] Dictionary 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
+ });
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/AdminUserController.cs b/FinlyticBackend/Controllers/AdminUserController.cs
new file mode 100644
index 0000000..3e7a57f
--- /dev/null
+++ b/FinlyticBackend/Controllers/AdminUserController.cs
@@ -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;
+
+///
+/// DTO for admin password reset requests (AOT-compliant).
+///
+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;
+ }
+
+ ///
+ /// Creates a new user account by an administrator.
+ ///
+ [HttpPost("users")]
+ public async Task 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);
+ }
+
+ ///
+ /// Retrieves a list of all registered users.
+ ///
+ [HttpGet("users")]
+ public async Task GetAllUsers(CancellationToken cancellationToken)
+ {
+ var users = await _userService.GetAllUsersAsync(cancellationToken);
+ return Ok(users);
+ }
+
+ ///
+ /// Updates an existing user account.
+ ///
+ [HttpPut("users/{id:guid}")]
+ public async Task 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);
+ }
+
+ ///
+ /// Deactivates a user account.
+ ///
+ [HttpDelete("users/{id:guid}")]
+ public async Task 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." });
+ }
+
+ ///
+ /// Resets a user's password and forces password change on next login.
+ ///
+ [HttpPost("users/{id:guid}/reset-password")]
+ public async Task 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;
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/AnalyzeController.cs b/FinlyticBackend/Controllers/AnalyzeController.cs
new file mode 100644
index 0000000..db4c4b9
--- /dev/null
+++ b/FinlyticBackend/Controllers/AnalyzeController.cs
@@ -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;
+
+///
+/// Request payload for triggering manual analysis (AOT-compliant).
+///
+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 _logger;
+
+ public AnalyzeController(WebMqttClient mqttClient, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Triggers a manual analysis for an asset by gathering context in parallel and dispatching to FinlyticAnalyzer.
+ ///
+ [HttpPost("manual")]
+ public async Task 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(
+ "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." });
+ }
+ }
+
+ ///
+ /// Fetches the currently active trade proposals from the FinlyticTrades service.
+ ///
+ [HttpGet("proposals")]
+ public async Task 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, GetTradesRequest>(
+ "trades_Get", request, TimeSpan.FromSeconds(4));
+
+ return Ok(proposals ?? new List());
+ }
+ 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 FetchTaDataAsync(IsinRequest request)
+ {
+ try
+ {
+ return await _mqttClient.SendRpcRequestAsync(
+ "ta_GetAnalysis", request, TimeSpan.FromSeconds(2));
+ }
+ catch { return null; }
+ }
+
+ private async Task FetchFundamentalsDataAsync(IsinRequest request)
+ {
+ try
+ {
+ return await _mqttClient.SendRpcRequestAsync(
+ "fundamentals_Get", request, TimeSpan.FromSeconds(2));
+ }
+ catch { return null; }
+ }
+
+ private async Task FetchSentimentDataAsync(IsinRequest request)
+ {
+ try
+ {
+ return await _mqttClient.SendRpcRequestAsync(
+ "sentiment_GetIsin", request, TimeSpan.FromSeconds(2));
+ }
+ catch { return null; }
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/AssetsController.cs b/FinlyticBackend/Controllers/AssetsController.cs
new file mode 100644
index 0000000..c3d732a
--- /dev/null
+++ b/FinlyticBackend/Controllers/AssetsController.cs
@@ -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;
+
+///
+/// DTO for asset search results (AOT-compliant).
+///
+public record AssetSearchResultDto(
+ [property: JsonPropertyName("symbol")] string Symbol,
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("isin")] string Isin,
+ [property: JsonPropertyName("image")] string Image
+);
+
+///
+/// DTO for discovery assets (AOT-compliant).
+///
+public record DiscoveryAssetResponseDto(
+ [property: JsonPropertyName("isin")] string Isin,
+ [property: JsonPropertyName("symbol")] string Symbol,
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("type")] string Type,
+ [property: JsonPropertyName("category")] string Category,
+ [property: JsonPropertyName("image")] string Image,
+ [property: JsonPropertyName("tags")] List Tags
+);
+
+[ApiController]
+[Authorize]
+[Route("api/v1/assets")]
+public class AssetsController : ControllerBase
+{
+ private readonly WebMqttClient _mqttClient;
+ private readonly BackendDbContext _dbContext;
+ private readonly ILogger _logger;
+
+ private static List? _cachedIndexAssets = null;
+
+ private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes("""
+
+ """);
+
+ public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _dbContext = dbContext;
+ _logger = logger;
+ }
+
+ ///
+ /// Lädt den AssetIndex direkt aus der gemounteten index.json.
+ ///
+ public static List LoadAssetsFromIndexJson()
+ {
+ if (_cachedIndexAssets != null && _cachedIndexAssets.Count > 0)
+ {
+ return _cachedIndexAssets;
+ }
+
+ var filePath = Path.Combine(Volumes.IndexRelativePath, "index.json");
+
+ if (System.IO.File.Exists(filePath))
+ {
+ try
+ {
+ string json = System.IO.File.ReadAllText(filePath);
+ var assets = JsonSerializer.Deserialize>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex);
+ if (assets != null && assets.Count > 0)
+ {
+ _cachedIndexAssets = assets;
+ return assets;
+ }
+ }
+ catch { }
+ }
+
+ return [];
+ }
+
+ ///
+ /// Durchsucht Assets im Index.
+ ///
+ [HttpGet("search")]
+ public IActionResult SearchAssets([FromQuery] string q = "")
+ {
+ string query = q?.Trim() ?? "";
+
+ var allAssets = LoadAssetsFromIndexJson();
+
+ var localMatches = !string.IsNullOrWhiteSpace(query)
+ ? allAssets.Where(a =>
+ (!string.IsNullOrEmpty(a.Name) && a.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
+ (!string.IsNullOrEmpty(a.Isin) && a.Isin.Contains(query, StringComparison.OrdinalIgnoreCase))
+ ).ToList()
+ : allAssets.Take(50).ToList();
+
+ var results = localMatches.Select(a => new AssetSearchResultDto(
+ Symbol: a.Isin,
+ Name: a.Name,
+ Isin: a.Isin,
+ Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}"
+ )).Take(50).ToList();
+
+ return Ok(results);
+ }
+
+ ///
+ /// Discovery-Endpunkt via RPC mit Fallback auf die index.json.
+ ///
+ [HttpGet("discovery")]
+ public async Task GetDiscoveryAssets([FromQuery] int limit = 15)
+ {
+ try
+ {
+ if (_mqttClient.IsConnected)
+ {
+ var rpcResult = await _mqttClient.SendRpcRequestAsync, GetDiscoveryAssetsRequest>(
+ "assets_GetDiscovery",
+ new GetDiscoveryAssetsRequest(limit),
+ TimeSpan.FromSeconds(3)
+ );
+
+ if (rpcResult != null && rpcResult.Count > 0)
+ {
+ var formatted = rpcResult.Select(a => new DiscoveryAssetResponseDto(
+ Isin: a.Isin,
+ Symbol: a.Isin,
+ Name: a.Name,
+ Type: a.Type,
+ Category: a.InstrumentCategory,
+ Image: !string.IsNullOrWhiteSpace(a.ImageId) ? a.ImageId : $"/api/v1/assets/logo/{a.Isin}",
+ Tags: a.Tags?.Select(t => t.Name).ToList() ?? new List()
+ )).ToList();
+
+ return Ok(formatted);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[AssetsController] RPC discovery fehlgeschlagen. Fallback auf index.json.");
+ }
+
+ var allAssets = LoadAssetsFromIndexJson();
+ var fallback = allAssets.Take(limit).Select(a => new DiscoveryAssetResponseDto(
+ Isin: a.Isin,
+ Symbol: a.Isin,
+ Name: a.Name,
+ Type: "stock",
+ Category: "Stock",
+ Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}",
+ Tags: new List()
+ )).ToList();
+
+ return Ok(fallback);
+ }
+
+ ///
+ /// Liest Fundamentaldaten per ISIN.
+ ///
+ [HttpGet("{isin}/fundamentals")]
+ public async Task GetFundamentals(
+ [FromRoute] string isin,
+ [FromQuery] string? ticker = null,
+ [FromQuery] bool forceRefresh = false,
+ CancellationToken cancellationToken = default)
+ {
+ string cleanIsin = isin.Trim().ToUpperInvariant();
+ string? cleanTicker = ticker?.Trim().ToUpperInvariant();
+
+ if (string.IsNullOrWhiteSpace(cleanIsin))
+ {
+ return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
+ }
+
+ try
+ {
+ if (_mqttClient.IsConnected)
+ {
+ var requestDto = new IsinRequest(
+ Isin: cleanIsin,
+ Ticker: cleanTicker,
+ ForceRefresh: forceRefresh
+ );
+
+ var rpcResult = await _mqttClient.SendRpcRequestAsync(
+ "fundamentals_Get",
+ requestDto,
+ TimeSpan.FromSeconds(forceRefresh ? 15 : 8)
+ );
+
+ if (rpcResult != null)
+ {
+ return Ok(rpcResult);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[ApiGateway] RPC fundamentals_Get für ISIN '{Isin}' fehlgeschlagen.", cleanIsin);
+ }
+
+ return NotFound(new { message = $"Keine Fundamentaldaten für ISIN '{cleanIsin}' vorhanden." });
+ }
+
+ ///
+ /// Liest Technische Analyse per ISIN.
+ ///
+ [HttpGet("{isin}/technicals")]
+ public async Task GetTechnicals(
+ [FromRoute] string isin,
+ [FromQuery] bool forceRefresh = false,
+ [FromQuery] string? ticker = null)
+ {
+ string normalizedSymbol = isin.Trim().ToUpperInvariant();
+ if (string.IsNullOrWhiteSpace(normalizedSymbol))
+ {
+ return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
+ }
+
+ try
+ {
+ if (_mqttClient.IsConnected)
+ {
+ var rpcResult = await _mqttClient.SendRpcRequestAsync(
+ "ta_GetAnalysis",
+ new IsinRequest(normalizedSymbol, ticker, forceRefresh),
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (rpcResult != null)
+ {
+ return Ok(rpcResult);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "RPC ta_GetAnalysis für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
+ }
+
+ return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
+ }
+
+ ///
+ /// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
+ ///
+ [HttpGet("logo/{isin}")]
+ public async Task GetAssetLogo([FromRoute] string isin)
+ {
+ if (string.IsNullOrWhiteSpace(isin)) return NotFound();
+
+ string cleanIsin = isin.Trim().ToUpperInvariant();
+
+ // Path Traversal Guard
+ string safeFileName = string.Concat(cleanIsin.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + ".svg";
+ string logoPath = Path.Combine(Volumes.LogosRelativePath, safeFileName);
+
+ if (System.IO.File.Exists(logoPath))
+ {
+ try
+ {
+ byte[] localData = await System.IO.File.ReadAllBytesAsync(logoPath);
+ return File(localData, "image/svg+xml");
+ }
+ catch { }
+ }
+
+ // Fallback: SVG Placeholder
+ return File(PlaceholderSvgBytes, "image/svg+xml");
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/AuthController.cs b/FinlyticBackend/Controllers/AuthController.cs
new file mode 100644
index 0000000..43d3d8d
--- /dev/null
+++ b/FinlyticBackend/Controllers/AuthController.cs
@@ -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;
+
+///
+/// DTO representing the current user's profile (AOT-compliant).
+///
+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;
+ }
+
+ ///
+ /// Authenticates a user and returns a JWT token.
+ ///
+ [HttpPost("auth/login")]
+ public async Task 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);
+ }
+
+ ///
+ /// Changes the initial password required by an admin reset or creation.
+ ///
+ [HttpPost("auth/change-initial-password")]
+ public async Task 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." });
+ }
+
+ ///
+ /// Updates the user's FCM device token.
+ ///
+ [Authorize]
+ [HttpPost("user/fcm-token")]
+ public async Task 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." });
+ }
+
+ ///
+ /// Gets the current user's profile details.
+ ///
+ [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
+ ));
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/CalendarController.cs b/FinlyticBackend/Controllers/CalendarController.cs
new file mode 100644
index 0000000..6decc34
--- /dev/null
+++ b/FinlyticBackend/Controllers/CalendarController.cs
@@ -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;
+
+///
+/// Response DTO for corporate calendar events (AOT-compliant).
+///
+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 _logger;
+
+ public CalendarController(WebMqttClient mqttClient, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Retrieves corporate calendar events with optional filters.
+ ///
+ [HttpGet("events")]
+ public async Task 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, EmptyRequest>(
+ "events_GetAll",
+ new EmptyRequest(),
+ TimeSpan.FromSeconds(4)
+ );
+
+ if (rawEvents != null && rawEvents.Count > 0)
+ {
+ var allAssets = AssetsController.LoadAssetsFromIndexJson() ?? new List();
+ 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());
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/DashboardController.cs b/FinlyticBackend/Controllers/DashboardController.cs
new file mode 100644
index 0000000..f30a6f0
--- /dev/null
+++ b/FinlyticBackend/Controllers/DashboardController.cs
@@ -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
+{
+ ///
+ /// Retrieves global statistics aggregated from all microservices.
+ /// Currently returns mocked data for UI development.
+ ///
+ [HttpGet("stats")]
+ public IActionResult GetGlobalStats()
+ {
+ return Ok(new
+ {
+ totalAssetsLoaded = 12450,
+ totalNewsArticles = 8340,
+ activeTrades = 12,
+ systemUptimeHours = 342,
+ sentimentAnalysesCompleted = 45000,
+ technicalAnalysesCompleted = 120000
+ });
+ }
+}
diff --git a/FinlyticBackend/Controllers/NewsController.cs b/FinlyticBackend/Controllers/NewsController.cs
new file mode 100644
index 0000000..20db288
--- /dev/null
+++ b/FinlyticBackend/Controllers/NewsController.cs
@@ -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 _logger;
+
+ public NewsController(WebMqttClient mqttClient, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Retrieves paginated news articles with optional filters and enriched sentiment data.
+ ///
+ [HttpGet]
+ public async Task 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, 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(
+ "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());
+ }
+
+ ///
+ /// Retrieves sentiment summary for a specific ISIN.
+ ///
+ [HttpGet("sentiment/isin/{isin}")]
+ public async Task 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(
+ "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}." });
+ }
+
+ ///
+ /// Retrieves sentiment analysis for a specific article.
+ ///
+ [HttpGet("sentiment/article/{articleId}")]
+ public async Task 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(
+ "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}." });
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/UserFavoritesController.cs b/FinlyticBackend/Controllers/UserFavoritesController.cs
new file mode 100644
index 0000000..9bd5c7d
--- /dev/null
+++ b/FinlyticBackend/Controllers/UserFavoritesController.cs
@@ -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;
+
+///
+/// DTO representing a user's favorite asset.
+///
+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 _logger;
+
+ public UserFavoritesController(
+ BackendDbContext dbContext,
+ WebMqttClient mqttClient,
+ ILogger logger)
+ {
+ _dbContext = dbContext;
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Retrieves the user's favorite assets with parallelized RPC queries for maximum performance.
+ ///
+ [HttpGet]
+ public async Task 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());
+ }
+
+ // 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();
+ var seenKeys = new HashSet(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);
+ }
+
+ ///
+ /// Updates the selected ticker for a favorite asset.
+ ///
+ [HttpPost("{symbol}/ticker")]
+ public async Task 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." });
+ }
+
+ ///
+ /// Toggles the favorite status of an asset for the user.
+ ///
+ [HttpPost("{symbol}")]
+ public async Task 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
+ });
+ }
+
+ ///
+ /// Removes an asset from the user's favorites.
+ ///
+ [HttpDelete("{symbol}")]
+ public async Task 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(
+ "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(
+ "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 FetchAssetDetailsAsync(string cleanIsin, string? selectedTicker)
+ {
+ try
+ {
+ if (!_mqttClient.IsConnected) return null;
+
+ var rpcAssets = await _mqttClient.SendRpcRequestAsync, 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;
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/UserPreferencesController.cs b/FinlyticBackend/Controllers/UserPreferencesController.cs
new file mode 100644
index 0000000..1f29240
--- /dev/null
+++ b/FinlyticBackend/Controllers/UserPreferencesController.cs
@@ -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;
+ }
+
+ ///
+ /// Request payload for updating the user's theme.
+ /// Supported IDs for FluentAvalonia: fluent_dark, fluent_light, fluent_accent, dark_classic.
+ ///
+ public record UpdateThemeRequest(string ThemeId);
+
+ ///
+ /// Gets current user preferences including FluentAvalonia theme preference.
+ ///
+ [HttpGet]
+ public async Task 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
+ });
+ }
+
+ ///
+ /// Updates current user theme preference in PostgreSQL database.
+ ///
+ [HttpPut("theme")]
+ public async Task 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
+ });
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/UserTradesController.cs b/FinlyticBackend/Controllers/UserTradesController.cs
new file mode 100644
index 0000000..edfe796
--- /dev/null
+++ b/FinlyticBackend/Controllers/UserTradesController.cs
@@ -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 _logger;
+
+ public UserTradesController(WebMqttClient mqttClient, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Liest die eindeutige UserId aus den Claims des authentifizierten Bearer Tokens.
+ ///
+ 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";
+ }
+
+ ///
+ /// Retrieves a list of trades for the current authenticated user (including global proposals).
+ ///
+ [HttpGet]
+ public async Task 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, GetTradesRequest>(
+ "trades_Get",
+ request,
+ TimeSpan.FromSeconds(5));
+
+ return Ok(trades ?? new List());
+ }
+ 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" });
+ }
+ }
+
+ ///
+ /// Public endpoint to retrieve active global proposals for guest users.
+ ///
+ [HttpGet("/api/v1/trades/public")]
+ [AllowAnonymous]
+ public async Task 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, GetTradesRequest>(
+ "trades_Get",
+ request,
+ TimeSpan.FromSeconds(5));
+
+ return Ok(proposals ?? new List());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to retrieve public trade proposals via MQTT RPC.");
+ return Ok(new List());
+ }
+ }
+
+ ///
+ /// Accepts a proposed trade and assigns it to the current user's portfolio.
+ ///
+ [HttpPost("accept")]
+ public async Task 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(
+ "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" });
+ }
+ }
+
+ ///
+ /// Closes an active trade.
+ ///
+ [HttpPost("{id}/close")]
+ public async Task 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(
+ $"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" });
+ }
+ }
+
+ ///
+ /// Rejects a proposed trade.
+ ///
+ [HttpPost("{id}/reject")]
+ public async Task 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(
+ $"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" });
+ }
+ }
+}
\ No newline at end of file
diff --git a/FinlyticBackend/Database/BackendDbContext.cs b/FinlyticBackend/Database/BackendDbContext.cs
new file mode 100644
index 0000000..ef65988
--- /dev/null
+++ b/FinlyticBackend/Database/BackendDbContext.cs
@@ -0,0 +1,75 @@
+using FinlyticBackend.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace FinlyticBackend.Database;
+
+public class BackendDbContext : DbContext
+{
+ public BackendDbContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Users => Set();
+ public DbSet UserDeviceTokens => Set();
+ public DbSet UserFavoriteAssets => Set();
+ public DbSet ServiceConfigurations => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("users");
+ entity.Property(u => u.Id).HasColumnName("id");
+ entity.Property(u => u.Email).HasColumnName("email");
+ entity.Property(u => u.PasswordHash).HasColumnName("password_hash");
+ entity.Property(u => u.FullName).HasColumnName("full_name");
+ entity.Property(u => u.Role).HasColumnName("role");
+ entity.Property(u => u.IsActive).HasColumnName("is_active");
+ entity.Property(u => u.CreatedAt).HasColumnName("created_at");
+ entity.Property(u => u.LastLoginAt).HasColumnName("last_login_at");
+ entity.Property(u => u.ThemePreference).HasColumnName("theme_preference");
+
+ entity.HasIndex(u => u.Email).IsUnique();
+ entity.HasIndex(u => u.Role);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("user_device_tokens");
+ entity.Property(t => t.Id).HasColumnName("id");
+ entity.Property(t => t.UserId).HasColumnName("user_id");
+ entity.Property(t => t.FcmToken).HasColumnName("fcm_token");
+ entity.Property(t => t.DeviceName).HasColumnName("device_name");
+ entity.Property(t => t.RegisteredAt).HasColumnName("registered_at");
+ entity.Property(t => t.LastUsedAt).HasColumnName("last_used_at");
+
+ entity.HasIndex(t => t.UserId);
+ entity.HasIndex(t => t.FcmToken);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("user_favorite_assets");
+ entity.Property(f => f.Id).HasColumnName("id");
+ entity.Property(f => f.UserId).HasColumnName("user_id");
+ entity.Property(f => f.Isin).HasColumnName("isin");
+ entity.Property(f => f.CreatedAt).HasColumnName("created_at");
+
+ entity.HasIndex(f => new { f.UserId, f.Isin }).IsUnique();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("service_configurations");
+ entity.Property(c => c.Id).HasColumnName("id");
+ entity.Property(c => c.ServiceName).HasColumnName("service_name");
+ entity.Property(c => c.ConfigKey).HasColumnName("config_key");
+ entity.Property(c => c.ConfigValue).HasColumnName("config_value");
+ entity.Property(c => c.DataType).HasColumnName("data_type");
+ entity.Property(c => c.Description).HasColumnName("description");
+ entity.Property(c => c.UpdatedAt).HasColumnName("updated_at");
+
+ entity.HasIndex(c => new { c.ServiceName, c.ConfigKey }).IsUnique();
+ });
+ }
+}
diff --git a/FinlyticBackend/Dockerfile b/FinlyticBackend/Dockerfile
new file mode 100644
index 0000000..b9c4d1f
--- /dev/null
+++ b/FinlyticBackend/Dockerfile
@@ -0,0 +1,16 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
+COPY ["FinlyticBackend/FinlyticBackend.csproj", "FinlyticBackend/"]
+RUN dotnet restore "FinlyticBackend/FinlyticBackend.csproj"
+COPY . .
+WORKDIR "/src/FinlyticBackend"
+RUN dotnet build "FinlyticBackend.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "FinlyticBackend.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "FinlyticBackend.dll"]
diff --git a/FinlyticBackend/Entities/ServiceConfigurationEntity.cs b/FinlyticBackend/Entities/ServiceConfigurationEntity.cs
new file mode 100644
index 0000000..6593169
--- /dev/null
+++ b/FinlyticBackend/Entities/ServiceConfigurationEntity.cs
@@ -0,0 +1,41 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticBackend.Entities;
+
+///
+/// Persisted service configuration setting stored in PostgreSQL.
+///
+[Table("service_configurations")]
+public class ServiceConfigurationEntity
+{
+ [Key]
+ [Column("id")]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ [MaxLength(100)]
+ [Column("service_name")]
+ public string ServiceName { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(100)]
+ [Column("config_key")]
+ public string ConfigKey { get; set; } = string.Empty;
+
+ [Required]
+ [Column("config_value")]
+ public string ConfigValue { get; set; } = string.Empty;
+
+ [MaxLength(50)]
+ [Column("data_type")]
+ public string DataType { get; set; } = "string"; // string, int, double, boolean, json
+
+ [MaxLength(255)]
+ [Column("description")]
+ public string Description { get; set; } = string.Empty;
+
+ [Column("updated_at")]
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticBackend/Entities/UserDeviceTokenEntity.cs b/FinlyticBackend/Entities/UserDeviceTokenEntity.cs
new file mode 100644
index 0000000..5ca09fc
--- /dev/null
+++ b/FinlyticBackend/Entities/UserDeviceTokenEntity.cs
@@ -0,0 +1,28 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticBackend.Entities;
+
+[Table("user_device_tokens")]
+public class UserDeviceTokenEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ public Guid UserId { get; set; }
+
+ [ForeignKey(nameof(UserId))]
+ public UserEntity? User { get; set; }
+
+ [Required]
+ [MaxLength(500)]
+ public string FcmToken { get; set; } = string.Empty;
+
+ [MaxLength(100)]
+ public string DeviceName { get; set; } = "MobileDevice";
+
+ public DateTime RegisteredAt { get; set; } = DateTime.UtcNow;
+ public DateTime LastUsedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticBackend/Entities/UserEntity.cs b/FinlyticBackend/Entities/UserEntity.cs
new file mode 100644
index 0000000..1e57fa0
--- /dev/null
+++ b/FinlyticBackend/Entities/UserEntity.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticBackend.Entities;
+
+[Table("users")]
+public class UserEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ [MaxLength(150)]
+ public string Email { get; set; } = string.Empty;
+
+ [Required]
+ public string PasswordHash { get; set; } = string.Empty;
+
+ [MaxLength(100)]
+ public string FullName { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(30)]
+ public string Role { get; set; } = "User"; // "Admin" | "User"
+
+ public bool IsActive { get; set; } = true;
+
+ public bool RequiresPasswordChange { get; set; } = false;
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? LastLoginAt { get; set; }
+
+ [MaxLength(50)]
+ public string ThemePreference { get; set; } = "dark_classic";
+
+ public List DeviceTokens { get; set; } = new();
+}
diff --git a/FinlyticBackend/Entities/UserFavoriteAssetEntity.cs b/FinlyticBackend/Entities/UserFavoriteAssetEntity.cs
new file mode 100644
index 0000000..c06439e
--- /dev/null
+++ b/FinlyticBackend/Entities/UserFavoriteAssetEntity.cs
@@ -0,0 +1,27 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticBackend.Entities;
+
+[Table("user_favorite_assets")]
+public class UserFavoriteAssetEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ public Guid UserId { get; set; }
+
+ [ForeignKey(nameof(UserId))]
+ public UserEntity? User { get; set; }
+
+ [Required]
+ [MaxLength(30)]
+ public string Isin { get; set; } = string.Empty;
+
+ [MaxLength(50)]
+ public string? SelectedTicker { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticBackend/FinlyticBackend.csproj b/FinlyticBackend/FinlyticBackend.csproj
new file mode 100644
index 0000000..e88aca0
--- /dev/null
+++ b/FinlyticBackend/FinlyticBackend.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FinlyticBackend/Hubs/FavoritesPriceHub.cs b/FinlyticBackend/Hubs/FavoritesPriceHub.cs
new file mode 100644
index 0000000..57bdfef
--- /dev/null
+++ b/FinlyticBackend/Hubs/FavoritesPriceHub.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Hubs;
+
+///
+/// SignalR Hub streaming real-time stock prices & daily % growth updates for favorite assets every 10 seconds.
+///
+public class FavoritesPriceHub : Hub
+{
+ private readonly ILogger _logger;
+
+ public FavoritesPriceHub(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override async Task OnConnectedAsync()
+ {
+ _logger.LogInformation("[FavoritesPriceHub] SignalR client connected: ConnectionId={ConnectionId}", Context.ConnectionId);
+ await base.OnConnectedAsync();
+ }
+
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ _logger.LogInformation("[FavoritesPriceHub] SignalR client disconnected: ConnectionId={ConnectionId}", Context.ConnectionId);
+ await base.OnDisconnectedAsync(exception);
+ }
+}
diff --git a/FinlyticBackend/Hubs/NewsHub.cs b/FinlyticBackend/Hubs/NewsHub.cs
new file mode 100644
index 0000000..05aa0ec
--- /dev/null
+++ b/FinlyticBackend/Hubs/NewsHub.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.SignalR;
+using System.Threading.Tasks;
+using FinlyticCore.Dtos.News;
+
+namespace FinlyticBackend.Hubs;
+
+///
+/// SignalR Hub for real-time news delivery to connected clients.
+///
+public class NewsHub : Hub
+{
+ // Clients can call this to join specific symbol groups if needed later
+ public async Task SubscribeToSymbol(string symbol)
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, symbol.ToUpperInvariant());
+ }
+
+ public async Task UnsubscribeFromSymbol(string symbol)
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, symbol.ToUpperInvariant());
+ }
+}
diff --git a/FinlyticBackend/Hubs/SystemHealthHub.cs b/FinlyticBackend/Hubs/SystemHealthHub.cs
new file mode 100644
index 0000000..108cf6f
--- /dev/null
+++ b/FinlyticBackend/Hubs/SystemHealthHub.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Hubs;
+
+///
+/// SignalR Hub broadcasting real-time system diagnostics & microservice health updates to connected clients.
+///
+public class SystemHealthHub : Hub
+{
+ private readonly ILogger _logger;
+
+ public SystemHealthHub(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override async Task OnConnectedAsync()
+ {
+ _logger.LogInformation("[SystemHealthHub] Client connected: ConnectionId={ConnectionId}", Context.ConnectionId);
+ await base.OnConnectedAsync();
+ }
+
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ _logger.LogInformation("[SystemHealthHub] Client disconnected: ConnectionId={ConnectionId}", Context.ConnectionId);
+ await base.OnDisconnectedAsync(exception);
+ }
+}
diff --git a/FinlyticBackend/Hubs/TradeHub.cs b/FinlyticBackend/Hubs/TradeHub.cs
new file mode 100644
index 0000000..e8f21d6
--- /dev/null
+++ b/FinlyticBackend/Hubs/TradeHub.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace FinlyticBackend.Hubs;
+
+///
+/// SignalR Hub broadcasting real-time trade signals, position updates, and closed trade events.
+///
+public class TradeHub : Hub
+{
+ public override async Task OnConnectedAsync()
+ {
+ await base.OnConnectedAsync();
+ }
+}
diff --git a/FinlyticBackend/Hubs/TradeRealtimeHub.cs b/FinlyticBackend/Hubs/TradeRealtimeHub.cs
new file mode 100644
index 0000000..ad32641
--- /dev/null
+++ b/FinlyticBackend/Hubs/TradeRealtimeHub.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Threading.Tasks;
+using FinlyticCore.Models.Auth;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Hubs;
+
+///
+/// Real-time SignalR WebSocket & Server-Sent Events (SSE) Hub streaming live trade proposals & updates to Web and Mobile clients.
+///
+public class TradeRealtimeHub : Hub
+{
+ private readonly ILogger _logger;
+
+ public TradeRealtimeHub(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override async Task OnConnectedAsync()
+ {
+ _logger.LogInformation("Real-time SignalR Client connected: ConnectionId={ConnectionId}, User={User}",
+ Context.ConnectionId, Context.User?.Identity?.Name ?? "Anonymous");
+ await base.OnConnectedAsync();
+ }
+
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ _logger.LogInformation("Real-time SignalR Client disconnected: ConnectionId={ConnectionId}", Context.ConnectionId);
+ await base.OnDisconnectedAsync(exception);
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260801073259_Init.Designer.cs b/FinlyticBackend/Migrations/20260801073259_Init.Designer.cs
new file mode 100644
index 0000000..32a9eca
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260801073259_Init.Designer.cs
@@ -0,0 +1,232 @@
+//
+using System;
+using FinlyticBackend.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ [DbContext(typeof(BackendDbContext))]
+ [Migration("20260801073259_Init")]
+ partial class Init
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticBackend.Entities.ServiceConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ConfigKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("config_key");
+
+ b.Property("ConfigValue")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("config_value");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("data_type");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("description");
+
+ b.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("service_name");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServiceName", "ConfigKey")
+ .IsUnique();
+
+ b.ToTable("service_configurations", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("device_name");
+
+ b.Property("FcmToken")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("fcm_token");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("registered_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FcmToken");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_device_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)")
+ .HasColumnName("email");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("ThemePreference")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("theme_preference");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Role");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("isin");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Isin")
+ .IsUnique();
+
+ b.ToTable("user_favorite_assets", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany("DeviceTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Navigation("DeviceTokens");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260801073259_Init.cs b/FinlyticBackend/Migrations/20260801073259_Init.cs
new file mode 100644
index 0000000..e0ea1b5
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260801073259_Init.cs
@@ -0,0 +1,142 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ ///
+ public partial class Init : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "service_configurations",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ service_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ config_key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ config_value = table.Column(type: "text", nullable: false),
+ data_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ description = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_service_configurations", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "users",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ email = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
+ password_hash = table.Column(type: "text", nullable: false),
+ full_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ role = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ is_active = table.Column(type: "boolean", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_login_at = table.Column(type: "timestamp with time zone", nullable: true),
+ theme_preference = table.Column(type: "character varying(50)", maxLength: 50, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_users", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_device_tokens",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ fcm_token = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
+ device_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ registered_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_used_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_user_device_tokens", x => x.id);
+ table.ForeignKey(
+ name: "FK_user_device_tokens_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_favorite_assets",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ isin = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_user_favorite_assets", x => x.id);
+ table.ForeignKey(
+ name: "FK_user_favorite_assets_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_service_configurations_service_name_config_key",
+ table: "service_configurations",
+ columns: new[] { "service_name", "config_key" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_user_device_tokens_fcm_token",
+ table: "user_device_tokens",
+ column: "fcm_token");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_user_device_tokens_user_id",
+ table: "user_device_tokens",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_user_favorite_assets_user_id_isin",
+ table: "user_favorite_assets",
+ columns: new[] { "user_id", "isin" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_users_email",
+ table: "users",
+ column: "email",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_users_role",
+ table: "users",
+ column: "role");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "service_configurations");
+
+ migrationBuilder.DropTable(
+ name: "user_device_tokens");
+
+ migrationBuilder.DropTable(
+ name: "user_favorite_assets");
+
+ migrationBuilder.DropTable(
+ name: "users");
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260805174328_AddRequiresPasswordChange.Designer.cs b/FinlyticBackend/Migrations/20260805174328_AddRequiresPasswordChange.Designer.cs
new file mode 100644
index 0000000..02b3acc
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260805174328_AddRequiresPasswordChange.Designer.cs
@@ -0,0 +1,235 @@
+//
+using System;
+using FinlyticBackend.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ [DbContext(typeof(BackendDbContext))]
+ [Migration("20260805174328_AddRequiresPasswordChange")]
+ partial class AddRequiresPasswordChange
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticBackend.Entities.ServiceConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ConfigKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("config_key");
+
+ b.Property("ConfigValue")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("config_value");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("data_type");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("description");
+
+ b.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("service_name");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServiceName", "ConfigKey")
+ .IsUnique();
+
+ b.ToTable("service_configurations", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("device_name");
+
+ b.Property("FcmToken")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("fcm_token");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("registered_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FcmToken");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_device_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)")
+ .HasColumnName("email");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("RequiresPasswordChange")
+ .HasColumnType("boolean");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("ThemePreference")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("theme_preference");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Role");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("isin");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Isin")
+ .IsUnique();
+
+ b.ToTable("user_favorite_assets", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany("DeviceTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Navigation("DeviceTokens");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260805174328_AddRequiresPasswordChange.cs b/FinlyticBackend/Migrations/20260805174328_AddRequiresPasswordChange.cs
new file mode 100644
index 0000000..e940ac2
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260805174328_AddRequiresPasswordChange.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ ///
+ public partial class AddRequiresPasswordChange : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "RequiresPasswordChange",
+ table: "users",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "RequiresPasswordChange",
+ table: "users");
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260805195234_AddSelectedTickerToFavorites.Designer.cs b/FinlyticBackend/Migrations/20260805195234_AddSelectedTickerToFavorites.Designer.cs
new file mode 100644
index 0000000..13f70c2
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260805195234_AddSelectedTickerToFavorites.Designer.cs
@@ -0,0 +1,239 @@
+//
+using System;
+using FinlyticBackend.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ [DbContext(typeof(BackendDbContext))]
+ [Migration("20260805195234_AddSelectedTickerToFavorites")]
+ partial class AddSelectedTickerToFavorites
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticBackend.Entities.ServiceConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ConfigKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("config_key");
+
+ b.Property("ConfigValue")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("config_value");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("data_type");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("description");
+
+ b.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("service_name");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServiceName", "ConfigKey")
+ .IsUnique();
+
+ b.ToTable("service_configurations", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("device_name");
+
+ b.Property("FcmToken")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("fcm_token");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("registered_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FcmToken");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_device_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)")
+ .HasColumnName("email");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("RequiresPasswordChange")
+ .HasColumnType("boolean");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("ThemePreference")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("theme_preference");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Role");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("isin");
+
+ b.Property("SelectedTicker")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Isin")
+ .IsUnique();
+
+ b.ToTable("user_favorite_assets", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany("DeviceTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Navigation("DeviceTokens");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260805195234_AddSelectedTickerToFavorites.cs b/FinlyticBackend/Migrations/20260805195234_AddSelectedTickerToFavorites.cs
new file mode 100644
index 0000000..a668c86
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260805195234_AddSelectedTickerToFavorites.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ ///
+ public partial class AddSelectedTickerToFavorites : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "SelectedTicker",
+ table: "user_favorite_assets",
+ type: "character varying(50)",
+ maxLength: 50,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "SelectedTicker",
+ table: "user_favorite_assets");
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs b/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs
new file mode 100644
index 0000000..de105d6
--- /dev/null
+++ b/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs
@@ -0,0 +1,236 @@
+//
+using System;
+using FinlyticBackend.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ [DbContext(typeof(BackendDbContext))]
+ partial class BackendDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticBackend.Entities.ServiceConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ConfigKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("config_key");
+
+ b.Property("ConfigValue")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("config_value");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("data_type");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("description");
+
+ b.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("service_name");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServiceName", "ConfigKey")
+ .IsUnique();
+
+ b.ToTable("service_configurations", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("device_name");
+
+ b.Property("FcmToken")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("fcm_token");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("registered_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FcmToken");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_device_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)")
+ .HasColumnName("email");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("RequiresPasswordChange")
+ .HasColumnType("boolean");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("ThemePreference")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("theme_preference");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Role");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("isin");
+
+ b.Property("SelectedTicker")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Isin")
+ .IsUnique();
+
+ b.ToTable("user_favorite_assets", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany("DeviceTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Navigation("DeviceTokens");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticBackend/Program.cs b/FinlyticBackend/Program.cs
new file mode 100644
index 0000000..c733f6f
--- /dev/null
+++ b/FinlyticBackend/Program.cs
@@ -0,0 +1,199 @@
+using System.Security.Claims;
+using System.Text;
+using System.Threading.Tasks;
+using FinlyticBackend.Controllers;
+using FinlyticBackend.Database;
+using FinlyticBackend.Hubs;
+using FinlyticBackend.Services;
+using FinlyticBackend.Util;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http.Connections;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Tokens;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// 1. Add Controllers
+builder.Services.AddControllers();
+
+// 2. Define CORS Policy
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy("AllowAll", policy =>
+ {
+ policy.SetIsOriginAllowed(_ => true) // Allows any origin including Flutter Web localhost
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+ });
+});
+
+// 3. Configure JWT Authentication
+var secretKey = builder.Configuration["JWT:SecretKey"] ?? "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
+var issuer = builder.Configuration["JWT:Issuer"] ?? "FinlyticBackend";
+var audience = builder.Configuration["JWT:Audience"] ?? "FinlyticClients";
+
+builder.Services.AddAuthentication(options =>
+{
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+})
+.AddJwtBearer(options =>
+{
+ options.RequireHttpsMetadata = false;
+ options.SaveToken = true;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
+ ValidateIssuer = true,
+ ValidIssuer = issuer,
+ ValidateAudience = true,
+ ValidAudience = audience,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(5)
+ };
+
+ options.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = context =>
+ {
+ var accessToken = context.Request.Query["access_token"];
+ var path = context.HttpContext.Request.Path;
+ if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
+ {
+ context.Token = accessToken;
+ }
+ return Task.CompletedTask;
+ }
+ };
+});
+
+builder.Services.AddAuthorization();
+builder.Services.AddSignalR();
+
+// 4. Register DB Context & Services
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+
+builder.Services.AddHttpClient