diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs
index 6e5af67..1111b78 100644
--- a/Apis/api/Controllers/CvMatcherController.cs
+++ b/Apis/api/Controllers/CvMatcherController.cs
@@ -183,8 +183,11 @@ public sealed class CvMatcherController : ControllerBase
var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language },
ct);
- var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
- jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
+ if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
+ {
+ var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
+ jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
+ }
}
catch (Exception ex)
{
diff --git a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs
index 489d0ef..624eb8a 100644
--- a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs
+++ b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs
@@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses;
public sealed class CreateJobSearchTokenResponse
{
- public string TokenId { get; set; } = string.Empty;
+ ///
+ /// The generated token ID, or null when no job providers are currently enabled.
+ /// Callers must check for null before building the job-search link.
+ ///
+ public string? TokenId { get; set; }
}
diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs
index 5afaa9d..e81b3a9 100644
--- a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs
+++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs
@@ -7,9 +7,12 @@ public sealed class JobSearchSettings
public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15;
- public List Providers { get; set; } = [];
}
+///
+/// Runtime DTO for a job provider. Populated from cvSearch.JobProviders at session-creation
+/// time and snapshotted to JobSearchSessionEntity.ProviderConfigJson.
+///
public sealed class JobProviderConfig
{
public string Name { get; set; } = string.Empty;
diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs
index 195710b..8f04b35 100644
--- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs
+++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs
@@ -13,8 +13,11 @@ public interface IJobTokenService
/// Email address of the user who will receive the results.
/// Preferred language for result emails (e.g. "en", "ro").
/// Cancellation token.
- /// The generated token ID, to be embedded in the one-click job search link.
- Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
+ ///
+ /// The generated token ID to embed in the one-click job search link,
+ /// or null when no job providers are currently enabled (link should be suppressed).
+ ///
+ Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
///
/// Validates the token and, if valid, marks it as used and creates a Pending job search session.
diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs
index 421bccf..37a1e36 100644
--- a/Apis/cv-matcher-api/Services/JobTokenService.cs
+++ b/Apis/cv-matcher-api/Services/JobTokenService.cs
@@ -13,6 +13,9 @@ namespace Api.Services;
///
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
+/// Provider configuration is read from cvSearch.JobProviders at session-creation time and
+/// snapshotted into JobSearchSessionEntity.ProviderConfigJson so subsequent config changes
+/// do not affect already-queued sessions.
///
public sealed class JobTokenService : IJobTokenService
{
@@ -34,8 +37,15 @@ public sealed class JobTokenService : IJobTokenService
}
///
- public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
+ public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
{
+ var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
+ if (!hasEnabledProviders)
+ {
+ _logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders");
+ return null;
+ }
+
var token = new JobSearchTokenEntity
{
Id = Guid.NewGuid().ToString("N"),
@@ -67,8 +77,13 @@ public sealed class JobTokenService : IJobTokenService
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
+ var enabledProviders = await _db.JobProviders
+ .Where(p => p.Enabled)
+ .OrderBy(p => p.DisplayOrder)
+ .ToListAsync(ct);
+
var providerConfigJson = JsonSerializer.Serialize(
- _settings.Providers.Where(p => p.Enabled).ToList(),
+ enabledProviders.Select(ToConfig).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var session = new JobSearchSessionEntity
@@ -86,11 +101,41 @@ public sealed class JobTokenService : IJobTokenService
_db.JobSearchSessions.Add(session);
await _db.SaveChangesAsync(ct);
- _logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
+ _logger.LogInformation(
+ "Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}",
+ session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name)));
return StartJobSearchStatus.Started;
}
+ ///
+ /// Maps a to the DTO used by
+ /// cv-search-job. The InitialKeywords list is stored as a JSON array in the entity.
+ ///
+ private static JobProviderConfig ToConfig(JobProviderEntity entity)
+ {
+ List keywords;
+ try
+ {
+ keywords = JsonSerializer.Deserialize>(entity.InitialKeywordsJson,
+ new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
+ }
+ catch
+ {
+ keywords = [];
+ }
+
+ return new JobProviderConfig
+ {
+ Name = entity.Name,
+ Enabled = entity.Enabled,
+ SearchUrlTemplate = entity.SearchUrlTemplate,
+ JobLinkContains = entity.JobLinkContains,
+ InitialKeywords = keywords,
+ MaxResults = entity.MaxResults
+ };
+ }
+
///
/// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM).
/// Takes the first 5 usable lines, splits them into words, strips punctuation, and deduplicates.
diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs
index eb1ce80..db92305 100644
--- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs
+++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs
@@ -204,20 +204,27 @@ public sealed class CvSearchJobTask : IJobTask
///
/// Deserialises the provider configuration snapshot stored on the session.
- /// Falls back to the current live config when the snapshot is absent or unparseable.
+ /// Providers are always snapshotted from the DB at session-creation time, so the snapshot
+ /// should always be present. Returns an empty list (with a warning) when it is missing or corrupt.
///
private List GetProviders(string? providerConfigJson)
{
- if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList();
+ if (string.IsNullOrWhiteSpace(providerConfigJson))
+ {
+ _logger.LogWarning("Session has no provider config snapshot — returning empty provider list");
+ return [];
+ }
+
try
{
return JsonSerializer.Deserialize>(providerConfigJson,
new JsonSerializerOptions(JsonSerializerDefaults.Web))
- ?? _settings.Providers.Where(p => p.Enabled).ToList();
+ ?? [];
}
- catch
+ catch (Exception ex)
{
- return _settings.Providers.Where(p => p.Enabled).ToList();
+ _logger.LogWarning(ex, "Failed to deserialise provider config snapshot — returning empty provider list");
+ return [];
}
}