using System; using System.Collections.Generic; using System.Security.Claims; using System.Text.Json.Serialization; using System.Threading.Tasks; using FinlyticBackend.Util; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Trading; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Http; 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 BackendMqttBridge _mqttClient; private readonly ILogger _logger; public AnalyzeController(BackendMqttBridge mqttClient, ILogger logger) { _mqttClient = mqttClient; _logger = logger; } /// /// Resolves the authenticated caller's identity from the JWT NameIdentifier claim, exactly like /// EngineController.GetUserIdFromClaims. Every manual evaluation triggered through this controller /// is tagged with this identity as EngineEvaluationSnapshotEntity.TriggeredByUserId, so a request /// whose identity cannot be established must be rejected rather than silently recorded as anonymous. /// /// /// The NameIdentifier claim (or its sub/nameid fallbacks) is missing or is not a /// parseable . /// private Guid GetUserIdFromClaims() { var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? User.FindFirstValue("nameid"); if (Guid.TryParse(claimUserId, out var userId)) { return userId; } _logger.LogWarning("[AnalyzeController] Claim NameIdentifier missing or not a valid GUID for an authenticated request."); throw new UnauthorizedAccessException("The request does not carry a valid user identity claim."); } /// /// Triggers an on-demand analysis for an asset via FinlyticEngine. /// Always returns 200 OK with a full body — the analysis /// pipeline now reports the real, already-computed scores and AI reasoning even when it did not produce a /// proposal (Proposal == null), so there is no longer a "silent" 204 No Content outcome for a /// completed-but-rejected evaluation (Rules.md §4). 204 is gone entirely: the only remaining failure /// modes (missing ISIN/Symbol, unreachable engine, no RPC response, unexpected error) are standardized /// Problem Details (Rules.md §11) instead of anonymous status objects. /// [HttpPost("manual")] public async Task TriggerManualAnalysis([FromBody] AnalyzeRequest request) { if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol)) { return Problem(title: "ISIN or Symbol is required.", statusCode: StatusCodes.Status400BadRequest); } if (!_mqttClient.IsConnected) { return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable); } try { string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant(); // UserId always comes from the JWT, never from the request body - matches every other engine // request DTO with a UserId field (see EvaluateAssetRequest's doc comment). var userId = GetUserIdFromClaims(); var evalRequest = new EvaluateAssetRequest( Isin: targetIsin, UserId: userId, Ticker: null, ForceAiEvaluation: true ); var evaluation = await _mqttClient.SendRpcRequestAsync( "engine_EvaluateIsin", evalRequest, TimeSpan.FromSeconds(15)); if (evaluation == null) { // Null here means the RPC call itself timed out / got no response - a transport failure, not // a legitimate "evaluated, no proposal" business outcome (that is now always a populated DTO). return Problem(title: "FinlyticEngine did not respond to the evaluation request.", statusCode: StatusCodes.Status502BadGateway); } return Ok(evaluation); } catch (UnauthorizedAccessException) { return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized); } catch (Exception ex) { _logger.LogError(ex, "Failed to trigger manual analysis via FinlyticEngine."); return Problem(title: "Internal server error triggering analysis.", statusCode: StatusCodes.Status500InternalServerError); } } /// /// Fetches the currently active trade proposals from FinlyticEngine. /// [HttpGet("proposals")] public async Task GetActiveProposals() { if (!_mqttClient.IsConnected) { return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable); } try { var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50); var proposals = await _mqttClient.SendRpcRequestAsync, GetTradeProposalsRequest>( "engine_GetProposals", request, TimeSpan.FromSeconds(5)); return Ok(proposals ?? new List()); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch active proposals from FinlyticEngine."); return Problem(title: "Internal server error fetching proposals.", statusCode: StatusCodes.Status500InternalServerError); } } }