Add internet job search feature (cv-search-job)
Build and Push Docker Images / build (push) Failing after 1m36s

- New cv-search-models shared library: EF entities + CvSearchDbContext for cvSearch schema (JobSearchTokens, JobSearchSessions, JobSearchResults tables)
- New cv-search-job worker service: polls DB for pending sessions, scrapes job boards via configurable HTML scraping, runs LLM scoring via cv-matcher-api, emails ranked results
- cv-matcher-api: JobTokenService creates one-time tokens; JobSearchController handles link clicks and creates sessions
- api: proxies job-search start endpoint, appends job search link to match result email
- CI workflow updated to build and push myai-cv-search-job:staging image
- CLAUDE.md documentation added for all affected services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:56:23 +03:00
parent a0ae262afc
commit 6293fa89e3
38 changed files with 2074 additions and 18 deletions
@@ -0,0 +1,108 @@
using CvMatcher.Models.Responses;
using CvSearch.Models.Data.Entities;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MimeKit;
namespace CvSearchJob.Services;
public sealed class CvSearchEmailSender
{
private readonly IConfiguration _config;
private readonly ILogger<CvSearchEmailSender> _logger;
public CvSearchEmailSender(IConfiguration config, ILogger<CvSearchEmailSender> logger)
{
_config = config;
_logger = logger;
}
public async Task SendResultsAsync(
string toEmail,
string? attachmentPath,
IReadOnlyList<JobSearchResultEntity> results,
CancellationToken ct)
{
var smtpHost = _config["Smtp:Host"];
var smtpPort = int.TryParse(_config["Smtp:Port"], out var port) ? port : 587;
var smtpUser = _config["Smtp:Username"];
var smtpPass = _config["Smtp:Password"];
var useStartTls = bool.TryParse(_config["Smtp:UseStartTls"], out var tls) && tls;
var contactToEmail = _config["Contact:ToEmail"];
if (string.IsNullOrWhiteSpace(smtpHost)) return;
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
if (!string.IsNullOrWhiteSpace(contactToEmail) &&
!recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase)))
recipients.Add(contactToEmail);
if (recipients.Count == 0) return;
var body = BuildBody(results);
var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau";
var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
foreach (var recipient in recipients)
{
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(smtpUser!));
msg.To.Add(MailboxAddress.Parse(recipient));
msg.Subject = $"[{environmentName}] {subject}";
var builder = new BodyBuilder { TextBody = body };
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
builder.Attachments.Add(attachmentPath);
msg.Body = builder.ToMessageBody();
try
{
using var client = new SmtpClient();
var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(smtpHost, smtpPort, tls2, ct);
if (!string.IsNullOrWhiteSpace(smtpUser))
await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct);
await client.SendAsync(msg, ct);
await client.DisconnectAsync(true, ct);
_logger.LogInformation("Job search results email sent to {Recipient}", recipient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient);
}
}
}
private static string BuildBody(IReadOnlyList<JobSearchResultEntity> results)
{
if (results.Count == 0)
return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.";
var lines = new System.Text.StringBuilder();
lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:");
lines.AppendLine();
for (int i = 0; i < results.Count; i++)
{
var r = results[i];
var matchResp = TryParseResult(r.ResultJson);
lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
lines.AppendLine($" {r.JobUrl}");
if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary))
lines.AppendLine($" {matchResp.Summary}");
lines.AppendLine();
}
return lines.ToString();
}
private static JobMatchResponse? TryParseResult(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<JobMatchResponse>(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); }
catch { return null; }
}
}