using Api.Clients.Api.Contracts; using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using Models.Requests; using Models.Settings; using Api.Services.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.Annotations; using Common.Responses; using MyAi.Data.Services; namespace Api.Controllers; /// /// Proxy endpoints for the CV matcher API. These endpoints forward requests to the internal cv-matcher-api. /// [ApiController] [Route("api/cv-matcher")] [EnableRateLimiting("cvMatcher")] public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; private readonly IJobSearchApi _jobSearchApi; private readonly ICaptchaVerifier _captcha; private readonly FileStorageSettings _fileStorageSettings; private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly IEmailSender _emailSender; private readonly ITemplateService _templates; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, IJobSearchApi jobSearchApi, ICaptchaVerifier captcha, IOptions fileStorageSettings, IOptions jobSearchLinkSettings, IEmailSender emailSender, ITemplateService templates, ILogger logger) { _cvApi = cvApi; _jobSearchApi = jobSearchApi; _captcha = captcha; _fileStorageSettings = fileStorageSettings.Value; _jobSearchLinkSettings = jobSearchLinkSettings.Value; _emailSender = emailSender; _templates = templates; _logger = logger; } /// /// Proxies a CV PDF upload to the internal cv-matcher-api for indexing. /// Validates the reCAPTCHA token and GDPR consent before forwarding. /// Caches the uploaded file locally so it can be attached to the match result email. /// /// Multipart form containing the CV PDF, captcha token, and GDPR consent flag. /// Cancellation token. /// /// 200 OK with the document ID and cache status from cv-matcher-api; /// 400 Bad Request if the file is missing or captcha verification fails; /// 499 if the client cancelled the request; /// 502 Bad Gateway if the upstream cv-matcher-api call fails. /// [HttpPost("upload")] [RequestSizeLimit(8 * 1024 * 1024)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status502BadGateway)] [ProducesResponseType(typeof(ErrorResponse), 499)] [SwaggerOperation(Summary = "Upload CV", Description = "Proxy upload of a CV PDF to the internal cv-matcher-api.")] [SwaggerResponse(StatusCodes.Status200OK, "Upload succeeded")] [SwaggerResponse(StatusCodes.Status400BadRequest, "Missing or invalid input")] [SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")] public async Task UploadCv( [FromForm] UploadCvRequest request, CancellationToken ct) { if (request.File is null) { return BadRequest(new ErrorResponse { Error = "Missing CV PDF.", Code = "cv_file_missing" }); } var cv = request.File; var gdprConsent = request.GdprConsent; try { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var verdict = await _captcha.VerifyAsync(request.CaptchaToken ?? string.Empty, userIp, "cv_upload", ct); if (!verdict.Success) { _logger.LogWarning("Captcha verification failed for CV upload. IP={IP}", userIp); return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); } if (!gdprConsent) throw new InvalidOperationException("GDPR consent is required."); _logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}", cv.FileName, cv.Length, gdprConsent); var stream = cv.OpenReadStream(); var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf"); var res = await _cvApi.Upload(part, ct); await CacheUploadedCvAsync(cv, res.DocumentId, ct); return Ok(res); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _logger.LogWarning("CV upload proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) { // Forward upstream 4xx errors (e.g. "File is too large", "Only PDF files supported") // so the browser can display the actionable message rather than a generic 502. var body = await apiEx.GetContentAsAsync(); _logger.LogWarning("Upstream cv-matcher-api returned {Status} during CV upload: {Error}", (int)apiEx.StatusCode, body?.Error); return StatusCode((int)apiEx.StatusCode, body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); } catch (Exception ex) { _logger.LogError(ex, "CV upload proxy request failed."); return StatusCode(StatusCodes.Status502BadGateway, new ErrorResponse { Error = "CV matcher API request failed.", Code = "upstream_request_failed" }); } } /// /// Proxies a CV-to-job match request to the internal cv-matcher-api. /// Validates the reCAPTCHA token, then forwards the request and emails the scored result to the user. /// When an email is provided, also creates a one-time job-search token and appends the search link to the email. /// /// Match request containing the CV document ID, a job URL or inline description, and an optional recipient email. /// Cancellation token. /// /// 200 OK with the score, strengths, and gaps; /// 400 Bad Request if captcha verification fails; /// 499 if the client cancelled the request; /// 502 Bad Gateway if the upstream cv-matcher-api call fails. /// [HttpPost("match-job")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status502BadGateway)] [ProducesResponseType(typeof(ErrorResponse), 499)] [SwaggerOperation(Summary = "Match job", Description = "Proxy job matching request to the internal cv-matcher-api.")] [SwaggerResponse(StatusCodes.Status200OK, "Match succeeded")] [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request")] [SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")] public async Task MatchJob([FromBody] JobMatchRequest request, CancellationToken ct) { try { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var verdict = await _captcha.VerifyAsync(request.CaptchaToken ?? string.Empty, userIp, "match_job", ct); if (!verdict.Success) { _logger.LogWarning("Captcha verification failed for job match. IP={IP}", userIp); return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); } request.ClientIpAddress = userIp; _logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}", request.CvDocumentId, !string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobDescription)); var res = await _cvApi.MatchJob(request, ct); var attachmentPath = TryGetCachedCvPath(request.CvDocumentId); var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl) ? request.JobUrl : "Manual job description"; var language = NormalizeLanguage(request.Language); string? jobSearchLink = null; if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) { try { var tokenResp = await _jobSearchApi.CreateTokenAsync( new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location }, ct); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) { var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; } } catch (Exception ex) { _logger.LogWarning(ex, "Could not create job search token. Email link will be omitted."); } } await _emailSender.SendMatchAsync( request.Email, _emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language), _emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink), attachmentPath, ct); return Ok(res); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _logger.LogWarning("Job match proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) { // Forward upstream 4xx errors (e.g. "Could not extract enough job text", // "Invalid job URL") so the browser can display the actionable message. var body = await apiEx.GetContentAsAsync(); _logger.LogWarning("Upstream cv-matcher-api returned {Status} during job match: {Error}", (int)apiEx.StatusCode, body?.Error); return StatusCode((int)apiEx.StatusCode, body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); } catch (Exception ex) { _logger.LogError(ex, "Job match proxy request failed."); return StatusCode(StatusCodes.Status502BadGateway, new ErrorResponse { Error = "CV matcher API request failed.", Code = "upstream_request_failed" }); } } /// /// Validates a one-time job-search token and kicks off the background job search. /// Returns a self-contained HTML page intended to be opened directly in the browser via the link in the match email. /// /// The one-time UUID token from the job-search link query string. /// Cancellation token. /// /// 200 OK with an HTML page indicating whether the search was started, the token was already used, expired, or invalid. /// Always returns 200 — error states are communicated via the HTML page content, not the HTTP status code. /// [HttpGet("job-search/start")] [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a self-contained HTML confirmation page.")] [SwaggerResponse(StatusCodes.Status200OK, "HTML page returned for all token states (started, already used, expired, invalid)")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task StartJobSearch([FromQuery] string t, CancellationToken ct) { try { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct); var lang = "en"; var (title, message) = result.Status switch { StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), _ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) }; return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); } catch (Exception ex) { _logger.LogError(ex, "Job search start failed for token {Token}.", t); var title = _templates.Get("html.job-search.error.title", "en"); var message = _templates.Get("html.job-search.error.message", "en"); return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); } } private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) { try { var storagePath = GetFileStoragePath(); Directory.CreateDirectory(storagePath); var targetPath = BuildCvPath(documentId); await using var fileStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); await file.CopyToAsync(fileStream, ct); _logger.LogInformation("Cached uploaded CV for email attachment. DocumentId={DocumentId}", documentId); } catch (Exception ex) { _logger.LogWarning(ex, "Could not cache uploaded CV for attachment. DocumentId={DocumentId}", documentId); } } private string? TryGetCachedCvPath(string? cvDocumentId) { if (string.IsNullOrWhiteSpace(cvDocumentId)) { return null; } var path = BuildCvPath(cvDocumentId); return System.IO.File.Exists(path) ? path : null; } private string BuildCvPath(string documentId) { var safeId = string.Concat(documentId.Where(char.IsLetterOrDigit)); if (string.IsNullOrWhiteSpace(safeId)) { safeId = "cv"; } return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf"); } private static string NormalizeLanguage(string? language) => string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); private string GetFileStoragePath() { var fileStoragePath = _fileStorageSettings.Path; if (!Path.IsPathRooted(fileStoragePath)) { var solutionRoot = Directory.GetCurrentDirectory(); if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase)) { solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot; } fileStoragePath = Path.Combine(solutionRoot, fileStoragePath); } return fileStoragePath; } }