@@ -6,5 +6,6 @@ public sealed class JobMatchRequest
|
|||||||
public string? JobUrl { get; set; }
|
public string? JobUrl { get; set; }
|
||||||
public string? JobDescription { get; set; }
|
public string? JobDescription { get; set; }
|
||||||
public bool GdprConsent { get; set; }
|
public bool GdprConsent { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
public string? CaptchaToken { get; set; }
|
public string? CaptchaToken { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using Api.Clients.Api.Contracts;
|
using Api.Clients.Api.Contracts;
|
||||||
using Models.Requests;
|
using Models.Requests;
|
||||||
|
using Models.Settings;
|
||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
|
using Api.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
using Shared.Models.Responses;
|
using Shared.Models.Responses;
|
||||||
|
|
||||||
@@ -18,15 +21,21 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly ICvMatcherApi _cvApi;
|
private readonly ICvMatcherApi _cvApi;
|
||||||
private readonly ICaptchaVerifier _captcha;
|
private readonly ICaptchaVerifier _captcha;
|
||||||
|
private readonly FileStorageSettings _fileStorageSettings;
|
||||||
|
private readonly IEmailSender _emailSender;
|
||||||
private readonly ILogger<CvMatcherController> _logger;
|
private readonly ILogger<CvMatcherController> _logger;
|
||||||
|
|
||||||
public CvMatcherController(
|
public CvMatcherController(
|
||||||
ICvMatcherApi cvApi,
|
ICvMatcherApi cvApi,
|
||||||
ICaptchaVerifier captcha,
|
ICaptchaVerifier captcha,
|
||||||
|
IOptions<FileStorageSettings> fileStorageSettings,
|
||||||
|
IEmailSender emailSender,
|
||||||
ILogger<CvMatcherController> logger)
|
ILogger<CvMatcherController> logger)
|
||||||
{
|
{
|
||||||
_cvApi = cvApi;
|
_cvApi = cvApi;
|
||||||
_captcha = captcha;
|
_captcha = captcha;
|
||||||
|
_fileStorageSettings = fileStorageSettings.Value;
|
||||||
|
_emailSender = emailSender;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +84,8 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
var stream = cv.OpenReadStream();
|
var stream = cv.OpenReadStream();
|
||||||
var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf");
|
var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf");
|
||||||
var res = await _cvApi.Upload(part, ct);
|
var res = await _cvApi.Upload(part, ct);
|
||||||
|
|
||||||
|
await CacheUploadedCvAsync(cv, res.DocumentId, ct);
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
@@ -120,6 +131,18 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
!string.IsNullOrWhiteSpace(request.JobUrl),
|
!string.IsNullOrWhiteSpace(request.JobUrl),
|
||||||
!string.IsNullOrWhiteSpace(request.JobDescription));
|
!string.IsNullOrWhiteSpace(request.JobDescription));
|
||||||
var res = await _cvApi.MatchJob(request, ct);
|
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);
|
return Ok(res);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
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" });
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ namespace Api.Services.Contracts
|
|||||||
Task SendContactAsync(ContactRequest req, CancellationToken ct);
|
Task SendContactAsync(ContactRequest req, CancellationToken ct);
|
||||||
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
|
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
|
||||||
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using MailKit.Security;
|
|||||||
using MimeKit;
|
using MimeKit;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
using Models.Requests;
|
using Models.Requests;
|
||||||
|
using CvMatcher.Models.Responses;
|
||||||
|
|
||||||
namespace Api.Services
|
namespace Api.Services
|
||||||
{
|
{
|
||||||
@@ -167,9 +168,77 @@ namespace Api.Services
|
|||||||
await client.DisconnectAsync(true, ct);
|
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<string>();
|
||||||
|
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"}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@
|
|||||||
<span data-i18n="cv.jobDescription">Or paste job description</span>
|
<span data-i18n="cv.jobDescription">Or paste job description</span>
|
||||||
<textarea id="jobDescription" rows="8" data-i18n-placeholder="cv.jobPlaceholder" placeholder="Paste the job description if the page cannot be crawled."></textarea>
|
<textarea id="jobDescription" rows="8" data-i18n-placeholder="cv.jobPlaceholder" placeholder="Paste the job description if the page cannot be crawled."></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="form.email">Email</span>
|
||||||
|
<input type="email" id="matchEmail" data-i18n-placeholder="form.emailPlaceholder" placeholder="name@company.com" />
|
||||||
|
</label>
|
||||||
<div class="consent-inline">
|
<div class="consent-inline">
|
||||||
<input type="checkbox" id="gdprConsent" required />
|
<input type="checkbox" id="gdprConsent" required />
|
||||||
<label for="gdprConsent" data-i18n="cv.gdpr">I agree that my CV is processed and stored.</label>
|
<label for="gdprConsent" data-i18n="cv.gdpr">I agree that my CV is processed and stored.</label>
|
||||||
|
|||||||
@@ -437,6 +437,7 @@
|
|||||||
var file = $('#cvFile')[0] && $('#cvFile')[0].files[0];
|
var file = $('#cvFile')[0] && $('#cvFile')[0].files[0];
|
||||||
var jobUrl = $('#jobUrl').val();
|
var jobUrl = $('#jobUrl').val();
|
||||||
var jobDescription = $('#jobDescription').val();
|
var jobDescription = $('#jobDescription').val();
|
||||||
|
var matchEmail = $('#matchEmail').val();
|
||||||
var consent = $('#gdprConsent').is(':checked');
|
var consent = $('#gdprConsent').is(':checked');
|
||||||
var $msg = $('#matcherMsg'),
|
var $msg = $('#matcherMsg'),
|
||||||
$button = $('#matchSubmit'),
|
$button = $('#matchSubmit'),
|
||||||
@@ -505,6 +506,7 @@
|
|||||||
cvDocumentId: cvData.documentId || cvData.cvDocumentId,
|
cvDocumentId: cvData.documentId || cvData.cvDocumentId,
|
||||||
jobUrl: jobUrl,
|
jobUrl: jobUrl,
|
||||||
jobDescription: jobDescription,
|
jobDescription: jobDescription,
|
||||||
|
email: matchEmail,
|
||||||
gdprConsent: consent,
|
gdprConsent: consent,
|
||||||
captchaToken: matchToken
|
captchaToken: matchToken
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user