Add internet job search feature (cv-search-job)
Build and Push Docker Images / build (push) Failing after 1m36s
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:
@@ -0,0 +1,45 @@
|
||||
# api — Public-Facing Proxy API
|
||||
|
||||
Internal port 8080. The only service exposed to the internet.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Validates reCAPTCHA on CV upload and match requests
|
||||
- Proxies CV operations to `cv-matcher-api` via Refit (`ICvMatcherApi`, `IJobSearchApi`)
|
||||
- Sends match result emails via SMTP (`SmtpEmailSender`)
|
||||
- Includes a job search link in match emails when a `CvDocumentId` is present
|
||||
- Serves the job-search-start page (`GET /api/cv-matcher/job-search/start?t=<token>`)
|
||||
- Enforces rate limiting (`cvMatcher` policy: 10 req / 10 min)
|
||||
- Enforces CORS (allow list from `Cors__AllowedOrigins__*` env vars)
|
||||
- Caches uploaded CV PDFs locally to `FileStorage:Path` for email attachment
|
||||
|
||||
## Key routes
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| POST | `/api/cv-matcher/upload` | Upload CV PDF, forward to cv-matcher-api |
|
||||
| POST | `/api/cv-matcher/match` | Match CV+job, send email with job search link |
|
||||
| GET | `/api/cv-matcher/job-search/start?t=<token>` | One-click job search start; returns plain HTML |
|
||||
| GET | `/api/health` | Health check |
|
||||
|
||||
## Job search link flow
|
||||
|
||||
1. After a successful match with an email, `CvMatcherController.MatchJob` calls `IJobSearchApi.CreateTokenAsync`
|
||||
2. Builds link: `{JobSearch:BaseUrl}/api/cv-matcher/job-search/start?t={tokenId}`
|
||||
3. Passes link to `SmtpEmailSender.BuildMatchEmailBody(result, jobSearchLink)`
|
||||
4. When user clicks link → `GET /api/cv-matcher/job-search/start?t=` → proxies to `cv-matcher-api POST /api/cv/job-search/token/{tokenId}/start`
|
||||
5. Returns styled HTML page (Started / AlreadyUsed / Expired / NotFound)
|
||||
|
||||
## Settings
|
||||
|
||||
| Section | Key env var | Notes |
|
||||
|---------|-------------|-------|
|
||||
| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Shared by both Refit clients |
|
||||
| `JobSearch` | `JobSearch__BaseUrl` | Base URL for link generation only (maps to `JobSearchLinkSettings.BaseUrl`) |
|
||||
| `FileStorage` | `FileStorage__Path` | Directory for cached CV PDFs; shared volume with cv-search-job |
|
||||
| `Smtp` | `Smtp__Host`, `Smtp__Username`, etc. | Used by SmtpEmailSender |
|
||||
| `Captcha` | `Captcha__SecretKey` | reCAPTCHA v3 secret |
|
||||
|
||||
## HTML page generation
|
||||
|
||||
`CvMatcherController.HtmlPage(title, message)` uses `$$"""` raw string literal so CSS `{` / `}` are literal. Do not change to `$"""` — causes CS9006.
|
||||
@@ -0,0 +1,14 @@
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Refit;
|
||||
|
||||
namespace Api.Clients.Api.Contracts;
|
||||
|
||||
public interface IJobSearchApi
|
||||
{
|
||||
[Post("/api/cv/job-search/token")]
|
||||
Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct);
|
||||
|
||||
[Post("/api/cv/job-search/token/{tokenId}/start")]
|
||||
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, CancellationToken ct);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Api.Clients.Api.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Models.Requests;
|
||||
using Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
@@ -20,21 +22,27 @@ namespace Api.Controllers;
|
||||
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 ILogger<CvMatcherController> _logger;
|
||||
|
||||
public CvMatcherController(
|
||||
ICvMatcherApi cvApi,
|
||||
IJobSearchApi jobSearchApi,
|
||||
ICaptchaVerifier captcha,
|
||||
IOptions<FileStorageSettings> fileStorageSettings,
|
||||
IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
|
||||
IEmailSender emailSender,
|
||||
ILogger<CvMatcherController> logger)
|
||||
{
|
||||
_cvApi = cvApi;
|
||||
_jobSearchApi = jobSearchApi;
|
||||
_captcha = captcha;
|
||||
_fileStorageSettings = fileStorageSettings.Value;
|
||||
_jobSearchLinkSettings = jobSearchLinkSettings.Value;
|
||||
_emailSender = emailSender;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -136,10 +144,27 @@ public sealed class CvMatcherController : ControllerBase
|
||||
? request.JobUrl
|
||||
: "Manual job description";
|
||||
|
||||
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 },
|
||||
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,
|
||||
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
|
||||
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel),
|
||||
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink),
|
||||
attachmentPath,
|
||||
ct);
|
||||
|
||||
@@ -157,6 +182,45 @@ public sealed class CvMatcherController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("job-search/start")]
|
||||
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")]
|
||||
public async Task<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
||||
var html = result.Status switch
|
||||
{
|
||||
StartJobSearchStatus.Started =>
|
||||
HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."),
|
||||
StartJobSearchStatus.AlreadyUsed =>
|
||||
HtmlPage("Link already used", "This job search link has already been used."),
|
||||
StartJobSearchStatus.Expired =>
|
||||
HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."),
|
||||
_ =>
|
||||
HtmlPage("Invalid link", "This job search link is not valid.")
|
||||
};
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
|
||||
return Content(HtmlPage("Error", "An error occurred. Please try again later."), "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
|
||||
|
||||
+16
-15
@@ -28,27 +28,28 @@ try
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
|
||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
}
|
||||
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var key = config["CvMatcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
var key = config["CvMatcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
});
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.IJobSearchApi>()
|
||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API");
|
||||
builder.Services.ConfigureCaddyForwardedHeaders();
|
||||
|
||||
@@ -214,7 +214,9 @@ namespace Api.Services
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result
|
||||
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null)
|
||||
{
|
||||
var body = $@"CV Matcher result
|
||||
|
||||
CV Document ID: {cvDocumentId}
|
||||
Job: {jobLabel ?? "N/A"}
|
||||
@@ -233,6 +235,19 @@ Gaps:
|
||||
Recommendations:
|
||||
- {string.Join("\n- ", result.Recommendations)}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += $@"
|
||||
|
||||
---
|
||||
Vrei sa gasesti mai multe joburi potrivite CV-ului tau?
|
||||
Click: {jobSearchLink}
|
||||
(link valabil 7 zile)";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public static string BuildMatchEmailSubject(int score, string? jobLabel)
|
||||
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user