This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy endpoints for the CV matcher API. These endpoints forward requests to the internal cv-matcher-api.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/cv-matcher")]
|
||||
[EnableRateLimiting("cvMatcher")]
|
||||
public sealed class CvMatcherController : ControllerBase
|
||||
{
|
||||
private readonly ICvMatcherApi _cvApi;
|
||||
private readonly ICaptchaVerifier _captcha;
|
||||
private readonly FileStorageSettings _fileStorageSettings;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly ILogger<CvMatcherController> _logger;
|
||||
|
||||
public CvMatcherController(
|
||||
ICvMatcherApi cvApi,
|
||||
ICaptchaVerifier captcha,
|
||||
IOptions<FileStorageSettings> fileStorageSettings,
|
||||
IEmailSender emailSender,
|
||||
ILogger<CvMatcherController> logger)
|
||||
{
|
||||
_cvApi = cvApi;
|
||||
_captcha = captcha;
|
||||
_fileStorageSettings = fileStorageSettings.Value;
|
||||
_emailSender = emailSender;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a CV PDF to the cv-matcher-api.
|
||||
/// </summary>
|
||||
/// <param name="request">The uploaded CV request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
[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<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxy a job matching request to the cv-matcher-api.
|
||||
/// </summary>
|
||||
/// <param name="request">Job match request payload containing CV document id or job description/url.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
[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<IActionResult> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user