From c2896ce77bf5f62f965f5728b1309cb70a764cc6 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 8 May 2026 14:09:58 +0300 Subject: [PATCH] Changes --- api-models/Requests/JobMatchRequest.cs | 1 + api/Controllers/CvMatcherController.cs | 78 ++++++++++++++++++++++++++ api/Services/Contracts/IEmailSender.cs | 2 +- api/Services/SmtpEmailSender.cs | 73 +++++++++++++++++++++++- web/wwwroot/cv-matcher/index.html | 4 ++ web/wwwroot/js/main.js | 2 + 6 files changed, 157 insertions(+), 3 deletions(-) diff --git a/api-models/Requests/JobMatchRequest.cs b/api-models/Requests/JobMatchRequest.cs index 4c009cc..9aec051 100644 --- a/api-models/Requests/JobMatchRequest.cs +++ b/api-models/Requests/JobMatchRequest.cs @@ -6,5 +6,6 @@ public sealed class JobMatchRequest public string? JobUrl { get; set; } public string? JobDescription { get; set; } public bool GdprConsent { get; set; } + public string? Email { get; set; } public string? CaptchaToken { get; set; } } diff --git a/api/Controllers/CvMatcherController.cs b/api/Controllers/CvMatcherController.cs index b96ceb1..0e18c48 100644 --- a/api/Controllers/CvMatcherController.cs +++ b/api/Controllers/CvMatcherController.cs @@ -1,8 +1,11 @@ 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; @@ -18,15 +21,21 @@ 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; } @@ -75,6 +84,8 @@ public sealed class CvMatcherController : ControllerBase 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) @@ -120,6 +131,18 @@ public sealed class CvMatcherController : ControllerBase !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) @@ -133,4 +156,59 @@ public sealed class CvMatcherController : ControllerBase 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; + } } diff --git a/api/Services/Contracts/IEmailSender.cs b/api/Services/Contracts/IEmailSender.cs index ec79fcc..bfae8d2 100644 --- a/api/Services/Contracts/IEmailSender.cs +++ b/api/Services/Contracts/IEmailSender.cs @@ -7,6 +7,6 @@ namespace Api.Services.Contracts Task SendContactAsync(ContactRequest req, CancellationToken ct); Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct); Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct); - Task SendMatchAsync(string? explicitTo, string subject, string body, CancellationToken ct); + Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct); } } diff --git a/api/Services/SmtpEmailSender.cs b/api/Services/SmtpEmailSender.cs index 2e37ebd..341109c 100644 --- a/api/Services/SmtpEmailSender.cs +++ b/api/Services/SmtpEmailSender.cs @@ -5,6 +5,7 @@ using MailKit.Security; using MimeKit; using Models.Settings; using Models.Requests; +using CvMatcher.Models.Responses; namespace Api.Services { @@ -167,9 +168,77 @@ namespace Api.Services await client.DisconnectAsync(true, ct); } - public Task SendMatchAsync(string? explicitTo, string subject, string body, CancellationToken ct) + public Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) { - throw new NotImplementedException(); + return SendMatchInternalAsync(explicitTo, subject, body, attachmentPath, ct); } + + private async Task SendMatchInternalAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) + { + var recipients = new List(); + if (!string.IsNullOrWhiteSpace(explicitTo)) + { + recipients.Add(explicitTo); + } + + if (!string.IsNullOrWhiteSpace(_contact.ToEmail) && + !recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase))) + { + recipients.Add(_contact.ToEmail); + } + + if (recipients.Count == 0) + { + _log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)"); + return; + } + + foreach (var recipient in recipients) + { + _log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient); + + var msg = new MimeMessage(); + msg.From.Add(MailboxAddress.Parse(_smtp.Username)); + msg.To.Add(MailboxAddress.Parse(recipient)); + msg.Subject = $"[{_environmentName}] {subject}".Trim(); + + var builder = new BodyBuilder + { + TextBody = body + }; + + if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath)) + { + builder.Attachments.Add(attachmentPath); + } + + msg.Body = builder.ToMessageBody(); + + await SendEmailAsync(msg, "cv match email", ct); + _log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient); + } + } + + public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result + +CV Document ID: {cvDocumentId} +Job: {jobLabel ?? "N/A"} +Job URL: {result.JobUrl ?? "N/A"} +Score: {result.Score}% + +Summary: +{result.Summary} + +Strengths: +- {string.Join("\n- ", result.Strengths)} + +Gaps: +- {string.Join("\n- ", result.Gaps)} + +Recommendations: +- {string.Join("\n- ", result.Recommendations)}"; + + public static string BuildMatchEmailSubject(int score, string? jobLabel) + => $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}"; } } diff --git a/web/wwwroot/cv-matcher/index.html b/web/wwwroot/cv-matcher/index.html index bb952b9..9ea2937 100644 --- a/web/wwwroot/cv-matcher/index.html +++ b/web/wwwroot/cv-matcher/index.html @@ -105,6 +105,10 @@ Or paste job description +