using Api.Clients.Api.Contracts; using Models.Requests; using Models.Settings; using Api.Services.Contracts; using Api.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.Annotations; using Shared.Models.Responses; 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("cv-matcher")] public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; private readonly ICaptchaVerifier _captcha; private readonly FileStorageSettings _fileStorageSettings; private readonly IEmailSender _emailSender; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, ICaptchaVerifier captcha, IOptions fileStorageSettings, IEmailSender emailSender, ILogger logger) { _cvApi = cvApi; _captcha = captcha; _fileStorageSettings = fileStorageSettings.Value; _emailSender = emailSender; _logger = logger; } /// /// Upload a CV PDF to the cv-matcher-api. /// /// The uploaded CV request. /// Cancellation token. [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 (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" }); } } /// /// Proxy a job matching request to the cv-matcher-api. /// /// Job match request payload containing CV document id or job description/url. /// Cancellation token. [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" }); } _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"; await _emailSender.SendMatchAsync( request.Email, SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel), SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel), 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 (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" }); } } 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 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; } }