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,6 @@
|
||||
namespace Models.Settings;
|
||||
|
||||
public sealed class JobSearchLinkSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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"}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace CvMatcher.Models.Requests;
|
||||
|
||||
public sealed class CreateJobSearchTokenRequest
|
||||
{
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace CvMatcher.Models.Responses;
|
||||
|
||||
public sealed class CreateJobSearchTokenResponse
|
||||
{
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace CvMatcher.Models.Responses;
|
||||
|
||||
public sealed class StartJobSearchResponse
|
||||
{
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class StartJobSearchStatus
|
||||
{
|
||||
public const string Started = "Started";
|
||||
public const string AlreadyUsed = "AlreadyUsed";
|
||||
public const string Expired = "Expired";
|
||||
public const string NotFound = "NotFound";
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# cv-matcher-api — Internal CV Match Engine
|
||||
|
||||
Internal port 8082. Only reachable from `api` and `cv-search-job` via `X-Internal-Api-Key`.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Indexes CV PDFs into the RAG system via `rag-api`
|
||||
- Matches a CV against a job posting URL (scrapes job HTML, scores pair with LLM)
|
||||
- Manages job search tokens and sessions for the one-click job search feature
|
||||
- Owns two EF DbContexts: `CvMatcherDbContext` (schema `cvMatcher`) and `CvSearchDbContext` (schema `cvSearch`)
|
||||
- Runs EF migrations for both contexts on startup
|
||||
|
||||
## Key routes
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| POST | `/api/cv/upload` | Index CV PDF into RAG |
|
||||
| POST | `/api/cv/match-job` | Score CV against a job URL (LLM call) |
|
||||
| POST | `/api/cv/find-jobs` | Find matching jobs from the RAG index |
|
||||
| POST | `/api/cv/job-search/token` | Create a job search token (called by api after a match) |
|
||||
| POST | `/api/cv/job-search/token/{tokenId}/start` | Validate token, create Pending session (called by api on link click) |
|
||||
| GET | `/api/health` | Health check |
|
||||
|
||||
## Core services
|
||||
|
||||
- `CvMatcherService` — orchestrates upload + match; calls `IRagApiClient` and `IMatcherAiClient`
|
||||
- `JobTextExtractor` — fetches a job page URL and extracts plain text
|
||||
- `JobTokenService` — creates tokens; validates + starts job search sessions; extracts CV keywords using simple heuristics (first 5 meaningful non-empty lines of CV text, split into words)
|
||||
|
||||
## AI providers
|
||||
|
||||
Configured under `Ai:Provider` (`OpenAI` or `Ollama`). Both providers implement `IMatcherAiClient`.
|
||||
Default model: `gpt-4o-mini`. Timeout: 90 s.
|
||||
|
||||
## Database contexts
|
||||
|
||||
Both contexts use the same SQL Server connection string (from `Database:*` settings).
|
||||
|
||||
- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly
|
||||
- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models")
|
||||
|
||||
## Keyword extraction (JobTokenService.ExtractKeywords)
|
||||
|
||||
No LLM call. Takes the first 5 non-empty lines of CV text that are:
|
||||
- Longer than 5 characters
|
||||
- Not purely numeric or contact-line patterns
|
||||
|
||||
Splits into words, strips punctuation, deduplicates, returns up to 10 comma-separated keywords.
|
||||
These keywords are stored in `JobSearchSessionEntity.Keywords` and used by `cv-search-job` for scraping.
|
||||
|
||||
## Settings
|
||||
|
||||
| Section | Notes |
|
||||
|---------|-------|
|
||||
| `Database` | Shared SQL Server connection |
|
||||
| `RagApi` | BaseUrl + InternalApiKey for rag-api |
|
||||
| `Ai` | Provider, model, timeout |
|
||||
| `Matcher` | TopK, DeepScoreTopN, MaxJobTextChars |
|
||||
| `JobSearch` | TokenExpiryDays, providers list (stored in session JSON) |
|
||||
| `InternalApi` | ApiKey used by UseInternalApiKeyProtection middleware |
|
||||
@@ -0,0 +1,56 @@
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/cv/job-search")]
|
||||
public sealed class JobSearchController : ControllerBase
|
||||
{
|
||||
private readonly IJobTokenService _tokenService;
|
||||
private readonly ILogger<JobSearchController> _logger;
|
||||
|
||||
public JobSearchController(IJobTokenService tokenService, ILogger<JobSearchController> logger)
|
||||
{
|
||||
_tokenService = tokenService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("token")]
|
||||
public async Task<ActionResult<CreateJobSearchTokenResponse>> CreateToken(
|
||||
[FromBody] CreateJobSearchTokenRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
|
||||
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
|
||||
|
||||
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, ct);
|
||||
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create job search token.");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to create token.", Code = "token_create_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("token/{tokenId}/start")]
|
||||
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _tokenService.TriggerStartAsync(tokenId, ct);
|
||||
return Ok(new StartJobSearchResponse { Status = status });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start job search for token {TokenId}.", tokenId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to start search.", Code = "start_failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ using Api.Data.Repositories.Contracts;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
@@ -34,6 +36,7 @@ try
|
||||
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
||||
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
|
||||
builder.Services.AddRefitClient<IRefitRagApi>()
|
||||
.ConfigureHttpClient((sp, c) =>
|
||||
@@ -61,8 +64,19 @@ try
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<CvSearchDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("cv-search-models");
|
||||
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
|
||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
|
||||
@@ -90,6 +104,11 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvMatcherDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete", ServiceName);
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface IJobTokenService
|
||||
{
|
||||
Task<string> CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct);
|
||||
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
public sealed class JobTokenService : IJobTokenService
|
||||
{
|
||||
private readonly CvSearchDbContext _db;
|
||||
private readonly IRagApiClient _rag;
|
||||
private readonly JobSearchSettings _settings;
|
||||
private readonly ILogger<JobTokenService> _logger;
|
||||
|
||||
public JobTokenService(
|
||||
CvSearchDbContext db,
|
||||
IRagApiClient rag,
|
||||
IOptions<JobSearchSettings> settings,
|
||||
ILogger<JobTokenService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_rag = rag;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct)
|
||||
{
|
||||
var token = new JobSearchTokenEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
CvDocumentId = cvDocumentId,
|
||||
Email = email,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
|
||||
Used = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.JobSearchTokens.Add(token);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId);
|
||||
return token.Id;
|
||||
}
|
||||
|
||||
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
|
||||
{
|
||||
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
|
||||
if (token is null) return StartJobSearchStatus.NotFound;
|
||||
if (token.Used) return StartJobSearchStatus.AlreadyUsed;
|
||||
if (token.ExpiresAt <= DateTime.UtcNow) return StartJobSearchStatus.Expired;
|
||||
|
||||
token.Used = true;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
|
||||
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
|
||||
|
||||
var providerConfigJson = JsonSerializer.Serialize(
|
||||
_settings.Providers.Where(p => p.Enabled).ToList(),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var session = new JobSearchSessionEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TokenId = token.Id,
|
||||
CvDocumentId = token.CvDocumentId,
|
||||
Email = token.Email,
|
||||
Status = JobSearchStatus.Pending,
|
||||
Keywords = keywords,
|
||||
ProviderConfigJson = providerConfigJson,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.JobSearchSessions.Add(session);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
|
||||
|
||||
return StartJobSearchStatus.Started;
|
||||
}
|
||||
|
||||
private static string ExtractKeywords(string cvText)
|
||||
{
|
||||
var lines = cvText
|
||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 5 && l.Length < 200)
|
||||
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
var words = lines
|
||||
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
|
||||
.Where(w => w.Length > 2)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return string.Join(",", words);
|
||||
}
|
||||
}
|
||||
@@ -106,5 +106,38 @@
|
||||
"TopK": 10,
|
||||
"DeepScoreTopN": 5,
|
||||
"MaxJobTextChars": 60000
|
||||
},
|
||||
"JobSearch": {
|
||||
"Enabled": true,
|
||||
"JobSearchLinkBaseUrl": "https://myai.ro",
|
||||
"TokenExpiryDays": 7,
|
||||
"MinMatchScore": 15,
|
||||
"MaxJobsToMatch": 15,
|
||||
"Providers": [
|
||||
{
|
||||
"Name": "ejobs.ro",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
|
||||
"JobLinkContains": "/user/locuri-de-munca/job/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
},
|
||||
{
|
||||
"Name": "bestjobs.eu",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
|
||||
"JobLinkContains": "/ro/locuri-de-munca/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
},
|
||||
{
|
||||
"Name": "linkedin.com",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
|
||||
"JobLinkContains": "/jobs/view/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\cv-search-models\cv-search-models.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CvSearch.Models.Data;
|
||||
|
||||
public sealed class CvSearchDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "cvSearch";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
|
||||
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
|
||||
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
|
||||
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<JobSearchTokenEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("JobSearchTokens");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||
entity.Property(x => x.Used).HasDefaultValue(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<JobSearchSessionEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("JobSearchSessions");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.TokenId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||
entity.Property(x => x.Status).HasMaxLength(32).IsRequired();
|
||||
entity.Property(x => x.Keywords).HasMaxLength(1000);
|
||||
entity.Property(x => x.ProviderConfigJson).IsRequired(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.Status);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<JobSearchResultEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("JobSearchResults");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.SessionId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.ProviderName).HasMaxLength(128);
|
||||
entity.Property(x => x.JobUrl).HasMaxLength(2048);
|
||||
entity.Property(x => x.JobTitle).HasMaxLength(512);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.SessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace CvSearch.Models.Data.Entities;
|
||||
|
||||
public sealed class JobSearchResultEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public string ProviderName { get; set; } = string.Empty;
|
||||
public string JobUrl { get; set; } = string.Empty;
|
||||
public string JobTitle { get; set; } = string.Empty;
|
||||
public string JobText { get; set; } = string.Empty;
|
||||
public int Score { get; set; }
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CvSearch.Models.Data.Entities;
|
||||
|
||||
public sealed class JobSearchSessionEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = JobSearchStatus.Pending;
|
||||
public string Keywords { get; set; } = string.Empty;
|
||||
public string? ProviderConfigJson { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static class JobSearchStatus
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Processing = "Processing";
|
||||
public const string Done = "Done";
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace CvSearch.Models.Data.Entities;
|
||||
|
||||
public sealed class JobSearchTokenEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public bool Used { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Models.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
[Migration("20260522093356_AddJobSearchTables")]
|
||||
partial class AddJobSearchTables
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvSearch")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("TokenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobSearchTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "cvSearch");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
|
||||
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Score = table.Column<int>(type: "int", nullable: false),
|
||||
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchResults_SessionId",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchResults",
|
||||
column: "SessionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchSessions_Status",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Models.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvSearch")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("TokenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CvSearch.Models.Settings;
|
||||
|
||||
public sealed class JobSearchSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
|
||||
public int TokenExpiryDays { get; set; } = 7;
|
||||
public int MinMatchScore { get; set; } = 15;
|
||||
public int MaxJobsToMatch { get; set; } = 15;
|
||||
public List<JobProviderConfig> Providers { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class JobProviderConfig
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string SearchUrlTemplate { get; set; } = string.Empty;
|
||||
public string JobLinkContains { get; set; } = string.Empty;
|
||||
public List<string> InitialKeywords { get; set; } = [];
|
||||
public int MaxResults { get; set; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>CvSearch.Models</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user