feat(discovery): schedule saved-search alerts

This commit is contained in:
cesnimda
2026-08-31 22:12:52 +02:00
parent 19a89018a0
commit b7029faaec
16 changed files with 423 additions and 113 deletions
@@ -1,4 +1,3 @@
using System.Net.Http.Headers;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
@@ -15,10 +14,8 @@ namespace JobTrackerApi.Controllers;
[Authorize(AuthenticationSchemes = "local")]
public sealed class JobDiscoveryController : ControllerBase
{
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
private readonly IHttpClientFactory _clients;
private readonly IConfiguration _configuration;
private readonly IMemoryCache _cache;
private readonly IJobDiscoverySearchService _discovery;
private readonly SavedJobSearchRunner? _savedSearchRunner;
private readonly JobTrackerContext? _db;
private readonly ICurrentUserService? _currentUser;
@@ -27,11 +24,12 @@ public sealed class JobDiscoveryController : ControllerBase
IConfiguration configuration,
IMemoryCache cache,
JobTrackerContext? db = null,
ICurrentUserService? currentUser = null)
ICurrentUserService? currentUser = null,
IJobDiscoverySearchService? discovery = null,
SavedJobSearchRunner? savedSearchRunner = null)
{
_clients = clients;
_configuration = configuration;
_cache = cache;
_discovery = discovery ?? new NavJobDiscoverySearchService(clients, configuration, cache, TimeProvider.System);
_savedSearchRunner = savedSearchRunner;
_db = db;
_currentUser = currentUser;
}
@@ -41,7 +39,7 @@ public sealed class JobDiscoveryController : ControllerBase
{
try
{
return Ok(await SearchCoreAsync(q, location, cancellationToken));
return Ok(await _discovery.SearchAsync(q, location, cancellationToken));
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
{
@@ -86,6 +84,19 @@ public sealed class JobDiscoveryController : ControllerBase
return NoContent();
}
[HttpPatch("saved-searches/{id:int}")]
public async Task<ActionResult<SavedSearchDto>> UpdateSavedSearch(int id, [FromBody] UpdateSavedSearchRequest request, CancellationToken cancellationToken)
{
if (!TryGetPersistence(out var db, out var owner)) return Unauthorized();
var search = await db.SavedJobSearches.Include(x => x.Results)
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, cancellationToken);
if (search is null) return NotFound();
search.IsActive = request.IsActive;
search.UpdatedAtUtc = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(ToDto(search));
}
[HttpPost("saved-searches/{id:int}/run")]
public async Task<ActionResult<SavedSearchRunDto>> RunSavedSearch(int id, CancellationToken cancellationToken)
{
@@ -95,26 +106,10 @@ public sealed class JobDiscoveryController : ControllerBase
try
{
var jobs = await SearchCoreAsync(search.Query, search.Location, cancellationToken);
var now = DateTimeOffset.UtcNow;
var existing = search.Results.ToDictionary(x => x.ExternalJobId, StringComparer.OrdinalIgnoreCase);
var results = new List<SavedSearchJobDto>(jobs.Count);
foreach (var job in jobs)
{
var isNew = !existing.TryGetValue(job.Id, out var state);
if (state is null)
{
state = new SavedJobSearchResult { ExternalJobId = job.Id, FirstSeenAtUtc = now, LastSeenAtUtc = now };
search.Results.Add(state);
}
else state.LastSeenAtUtc = now;
results.Add(new SavedSearchJobDto(job, isNew, state.IsDismissed));
}
search.LastRunAtUtc = now;
search.UpdatedAtUtc = now;
var runner = _savedSearchRunner ?? new SavedJobSearchRunner(db, _discovery, TimeProvider.System);
var run = await runner.RunAsync(search, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return Ok(new SavedSearchRunDto(ToDto(search), results));
return Ok(new SavedSearchRunDto(ToDto(search), run.Jobs.Select(item => new SavedSearchJobDto(item.Job, item.IsNew, item.IsDismissed)).ToList()));
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
{
@@ -141,79 +136,8 @@ public sealed class JobDiscoveryController : ControllerBase
return db is not null && owner.Length > 0;
}
private async Task<List<DiscoveredJob>> SearchCoreAsync(string? q, string? location, CancellationToken cancellationToken)
{
var retrievedAt = DateTimeOffset.UtcNow;
var token = await GetTokenAsync(cancellationToken);
var client = _clients.CreateClient();
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
var next = "/api/v1/feed";
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (page == 0) request.Headers.IfModifiedSince = DateTimeOffset.UtcNow.AddDays(-14);
using var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
{
var feed = item.GetProperty("_feed_entry");
var jobId = feed.GetProperty("uuid").GetString();
if (string.IsNullOrWhiteSpace(jobId)) continue;
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) { entries.Remove(jobId); continue; }
entries[jobId] = new DiscoveredJob(jobId,
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
feed.TryGetProperty("applicationDue", out var due) && due.TryGetDateTimeOffset(out var deadline) ? deadline : null,
$"https://arbeidsplassen.nav.no/stillinger/stilling/{jobId}", "nav", "NAV Arbeidsplassen", "searched", retrievedAt, "NO");
}
}
var query = (q ?? "").Trim();
var place = (location ?? "").Trim();
return entries.Values.Where(job => Contains(job.Title, query) || Contains(job.Company, query))
.Where(job => Contains(job.Location, place)).OrderByDescending(job => job.ModifiedAt).Take(100).ToList();
}
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
{
var configured = _configuration["NavJobs:Token"]?.Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
return await _cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
var text = await _clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
var token = text[start..].Trim();
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
return end < 0 ? token : token[..end];
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
}
private static bool Contains(string? value, string filter) =>
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
public sealed record DiscoveredJob(
string Id,
string Title,
string? Company,
string? Location,
DateTimeOffset? ModifiedAt,
DateTimeOffset? Deadline,
string Url,
string Source,
string SourceName,
string AcquisitionType,
DateTimeOffset RetrievedAt,
string CountryCode);
public sealed record SavedSearchRequest(string? Name, string? Query, string? Location);
public sealed record UpdateSavedSearchRequest(bool IsActive);
public sealed record DismissRequest(bool IsDismissed);
public sealed record SavedSearchDto(int Id, string Name, string Query, string Location, bool IsActive, DateTimeOffset? LastRunAtUtc, int ResultCount, int DismissedCount);
public sealed record SavedSearchJobDto(DiscoveredJob Job, bool IsNew, bool IsDismissed);
+15
View File
@@ -0,0 +1,15 @@
namespace JobTrackerApi.Models;
public sealed record DiscoveredJob(
string Id,
string Title,
string? Company,
string? Location,
DateTimeOffset? ModifiedAt,
DateTimeOffset? Deadline,
string Url,
string Source,
string SourceName,
string AcquisitionType,
DateTimeOffset RetrievedAt,
string CountryCode);
+3
View File
@@ -60,6 +60,8 @@ builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHand
builder.Services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
builder.Services.AddSingleton<AiOperationWorker>();
builder.Services.AddScoped<UserNotificationStore>();
builder.Services.AddSingleton<IJobDiscoverySearchService, NavJobDiscoverySearchService>();
builder.Services.AddScoped<SavedJobSearchRunner>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
@@ -174,6 +176,7 @@ builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>
builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<SavedJobSearchAlertHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
@@ -16,13 +16,28 @@ public sealed class BackgroundTenantRunner(
string worker,
Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken)
{
return await RunForOwnersAsync(worker, async db => await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
.Where(job => job.OwnerUserId != null).Select(job => job.OwnerUserId!).ToListAsync(cancellationToken), work, cancellationToken);
}
public async Task<BackgroundWorkerRunResult> RunForSavedSearchOwnersAsync(
string worker,
Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken)
{
return await RunForOwnersAsync(worker, async db => await db.SavedJobSearches.IgnoreQueryFilters().AsNoTracking()
.Where(search => search.IsActive).Select(search => search.OwnerUserId).ToListAsync(cancellationToken), work, cancellationToken);
}
private async Task<BackgroundWorkerRunResult> RunForOwnersAsync(
string worker,
Func<JobTrackerContext, Task<List<string>>> findOwners,
Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken)
{
await using var enumerationScope = scopes.CreateAsyncScope();
var ownerIds = await enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>()
.JobApplications.IgnoreQueryFilters().AsNoTracking()
.Where(job => job.OwnerUserId != null)
.Select(job => job.OwnerUserId!)
.ToListAsync(cancellationToken);
var ownerIds = await findOwners(enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>());
var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner))
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using System.Text.Json;
using JobTrackerApi.Models;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public interface IJobDiscoverySearchService
{
Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken);
}
public sealed class NavJobDiscoverySearchService(
IHttpClientFactory clients,
IConfiguration configuration,
IMemoryCache cache,
TimeProvider timeProvider) : IJobDiscoverySearchService
{
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
public async Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken)
{
var retrievedAt = timeProvider.GetUtcNow();
var token = await GetTokenAsync(cancellationToken);
var client = clients.CreateClient();
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
var next = "/api/v1/feed";
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (page == 0) request.Headers.IfModifiedSince = timeProvider.GetUtcNow().AddDays(-14);
using var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
{
var feed = item.GetProperty("_feed_entry");
var jobId = feed.GetProperty("uuid").GetString();
if (string.IsNullOrWhiteSpace(jobId)) continue;
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) { entries.Remove(jobId); continue; }
entries[jobId] = new DiscoveredJob(jobId,
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
feed.TryGetProperty("applicationDue", out var due) && due.TryGetDateTimeOffset(out var deadline) ? deadline : null,
$"https://arbeidsplassen.nav.no/stillinger/stilling/{jobId}", "nav", "NAV Arbeidsplassen", "searched", retrievedAt, "NO");
}
}
var term = (query ?? "").Trim();
var place = (location ?? "").Trim();
return entries.Values.Where(job => Contains(job.Title, term) || Contains(job.Company, term))
.Where(job => Contains(job.Location, place)).OrderByDescending(job => job.ModifiedAt).Take(100).ToList();
}
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
{
var configured = configuration["NavJobs:Token"]?.Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
return await cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
var text = await clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
var token = text[start..].Trim();
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
return end < 0 ? token : token[..end];
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
}
private static bool Contains(string? value, string filter) =>
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
}
@@ -0,0 +1,86 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed class SavedJobSearchAlertHostedService(
BackgroundTenantRunner tenants,
IConfiguration configuration,
ILogger<SavedJobSearchAlertHostedService> logger,
IStartupReadiness startupReadiness,
TimeProvider timeProvider) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
if (!IsEnabled())
{
logger.LogInformation("Saved job search alert worker disabled.");
return;
}
await Task.Delay(TimeSpan.FromSeconds(30), timeProvider, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
await RunOnceAsync(stoppingToken);
await Task.Delay(GetInterval(), timeProvider, stoppingToken);
}
}
public Task<BackgroundWorkerRunResult> RunOnceAsync(CancellationToken cancellationToken) =>
IsEnabled()
? tenants.RunForSavedSearchOwnersAsync("saved-job-search-alerts", ProcessOwnerAsync, cancellationToken)
: Task.FromResult(BackgroundWorkerRunResult.Disabled);
private bool IsEnabled() => configuration.GetValue("Workers:SavedJobSearchAlertsEnabled", true);
private TimeSpan GetInterval() => TimeSpan.FromHours(Math.Clamp(
configuration.GetValue("JobDiscovery:SavedSearchIntervalHours", 6), 1, 24));
private async Task ProcessOwnerAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var db = services.GetRequiredService<JobTrackerContext>();
var runner = services.GetRequiredService<SavedJobSearchRunner>();
var now = timeProvider.GetUtcNow();
var dueBefore = now - GetInterval();
var useNorwegian = string.Equals(
await db.Users.AsNoTracking().Where(user => user.Id == db.CurrentUserId).Select(user => user.UiLanguage).FirstOrDefaultAsync(cancellationToken),
"nb",
StringComparison.OrdinalIgnoreCase);
var searches = await db.SavedJobSearches.Include(search => search.Results)
.Where(search => search.IsActive)
.OrderBy(search => search.Id)
.ToListAsync(cancellationToken);
foreach (var search in searches.Where(search => search.LastRunAtUtc is null || search.LastRunAtUtc <= dueBefore))
{
var result = await runner.RunAsync(search, cancellationToken);
// The first automatic run establishes a baseline. Alert only for vacancies
// that appear after that baseline so existing results never look "new".
if (!result.WasInitialRun && result.NewCount > 0)
{
db.UserNotifications.Add(new UserNotification
{
Id = Guid.NewGuid(),
OwnerUserId = search.OwnerUserId,
Kind = "saved_search_results",
Title = useNorwegian
? result.NewCount == 1 ? "Ny stilling funnet" : $"{result.NewCount} nye stillinger funnet"
: result.NewCount == 1 ? "New vacancy found" : $"{result.NewCount} new vacancies found",
Message = useNorwegian
? result.NewCount == 1
? $"Det lagrede søket «{search.Name}» fant en ny stilling."
: $"Det lagrede søket «{search.Name}» fant {result.NewCount} nye stillinger."
: result.NewCount == 1
? $"Your saved search {search.Name} found a new vacancy."
: $"Your saved search {search.Name} found {result.NewCount} new vacancies.",
LinkPath = "/discover",
CreatedAtUtc = now.UtcDateTime
});
}
}
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,44 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record SavedJobSearchRunResult(
IReadOnlyList<SavedJobSearchJobResult> Jobs,
int NewCount,
bool WasInitialRun);
public sealed record SavedJobSearchJobResult(DiscoveredJob Job, bool IsNew, bool IsDismissed);
public sealed class SavedJobSearchRunner(
JobTrackerContext db,
IJobDiscoverySearchService discovery,
TimeProvider timeProvider)
{
public async Task<SavedJobSearchRunResult> RunAsync(SavedJobSearch search, CancellationToken cancellationToken)
{
var jobs = await discovery.SearchAsync(search.Query, search.Location, cancellationToken);
var now = timeProvider.GetUtcNow();
var wasInitialRun = search.LastRunAtUtc is null;
var existing = search.Results.ToDictionary(item => item.ExternalJobId, StringComparer.OrdinalIgnoreCase);
var results = new List<SavedJobSearchJobResult>(jobs.Count);
var newCount = 0;
foreach (var job in jobs)
{
var isNew = !existing.TryGetValue(job.Id, out var state);
if (state is null)
{
state = new SavedJobSearchResult { ExternalJobId = job.Id, FirstSeenAtUtc = now, LastSeenAtUtc = now };
search.Results.Add(state);
db.SavedJobSearchResults.Add(state);
newCount++;
}
else state.LastSeenAtUtc = now;
results.Add(new SavedJobSearchJobResult(job, isNew, state.IsDismissed));
}
search.LastRunAtUtc = now;
search.UpdatedAtUtc = now;
return new SavedJobSearchRunResult(results, newCount, wasInitialRun);
}
}
+4
View File
@@ -13,10 +13,14 @@
"Workers": {
"RulesEnabled": false,
"FollowUpRemindersEnabled": false,
"SavedJobSearchAlertsEnabled": true,
"DailyExportEnabled": false,
"JobEnrichmentEnabled": false,
"AiOperationsEnabled": false
},
"JobDiscovery": {
"SavedSearchIntervalHours": 6
},
"Ai": {
"ExternalProcessingEnabled": false,
"ExternalProvider": "ollama",