From d0d45bd2d346890e76d907d8ed10e9268e3a322d Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:46:44 +0300 Subject: [PATCH] feat(job-search): read providers from DB and suppress link when none enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JobTokenService.CreateTokenAsync queries cvSearch.JobProviders for any enabled row; returns null (no token created) when the table is empty or all providers are disabled. TriggerStartAsync snapshots enabled providers from DB at session-start time, preserving the existing snapshot contract. CvMatcherController guards link-building on a non-null TokenId so the "Start a job search" CTA is omitted from match emails when no providers are configured. JobSearchSettings.Providers list removed — provider config now lives exclusively in the DB. CvSearchJobTask.GetProviders falls back to an empty list with a warning (snapshot should always be populated from DB). Closes #35 Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 7 ++- .../Responses/CreateJobSearchTokenResponse.cs | 6 ++- .../Settings/JobSearchSettings.cs | 5 +- .../Services/Contracts/IJobTokenService.cs | 7 ++- .../Services/JobTokenService.cs | 51 +++++++++++++++++-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 17 +++++-- 6 files changed, 79 insertions(+), 14 deletions(-) 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 []; } }