Files
myAi/Apis/api/Controllers/CvMatcherController.cs
T
claude 7908dad181 Fix error propagation: surface API validation messages in the UI
- UseJsonExceptionHandler now maps InvalidOperationException to 400 (was 500),
  so upstream business-rule rejections reach the browser as actionable messages.
- CvMatcherController forwards Refit 4xx bodies from cv-matcher-api instead
  of swallowing them in a generic 502.
- ErrorResponse.Score removed; CaptchaController puts the score in Detail.
- Frontend extractApiError helper reads the server Error/error/title field for
  4xx responses and falls back to a generic i18n string for 5xx / missing body.
- All four failure handlers (CV upload, CV match, contact form, subscribe form)
  updated to use extractApiError with the correct rate-limit i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:41:24 +03:00

336 lines
16 KiB
C#

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;
/// <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 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<CvMatcherController> _logger;
public CvMatcherController(
ICvMatcherApi cvApi,
IJobSearchApi jobSearchApi,
ICaptchaVerifier captcha,
IOptions<FileStorageSettings> fileStorageSettings,
IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
IEmailSender emailSender,
ITemplateService templates,
ILogger<CvMatcherController> logger)
{
_cvApi = cvApi;
_jobSearchApi = jobSearchApi;
_captcha = captcha;
_fileStorageSettings = fileStorageSettings.Value;
_jobSearchLinkSettings = jobSearchLinkSettings.Value;
_emailSender = emailSender;
_templates = templates;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="request">Multipart form containing the CV PDF, captcha token, and GDPR consent flag.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 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.
/// </returns>
[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 (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<ErrorResponse>();
_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" });
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="request">Match request containing the CV document ID, a job URL or inline description, and an optional recipient email.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the <see cref="JobMatchResponse"/> 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.
/// </returns>
[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";
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 },
ct);
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<ErrorResponse>();
_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" });
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="t">The one-time UUID token from the job-search link query string.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 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.
/// </returns>
[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<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
{
try
{
var result = await _jobSearchApi.StartSearchAsync(t, ct);
var lang = "en";
var html = result.Status switch
{
StartJobSearchStatus.Started =>
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
StartJobSearchStatus.AlreadyUsed =>
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
StartJobSearchStatus.Expired =>
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
_ =>
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
};
return Content(html, "text/html");
}
catch (Exception ex)
{
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html");
}
}
private static string HtmlPage(string title, string message) => $$"""
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
</head>
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
</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;
}
}