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
+2
View File
@@ -104,6 +104,8 @@ EMAIL_FOLLOWUPREMINDERS_ENABLED=false
EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2 EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2
WORKER_RULES_ENABLED=false WORKER_RULES_ENABLED=false
WORKER_FOLLOWUP_REMINDERS_ENABLED=false WORKER_FOLLOWUP_REMINDERS_ENABLED=false
WORKER_SAVED_SEARCH_ALERTS_ENABLED=true
SAVED_SEARCH_INTERVAL_HOURS=6
WORKER_DAILY_EXPORT_ENABLED=false WORKER_DAILY_EXPORT_ENABLED=false
WORKER_JOB_ENRICHMENT_ENABLED=false WORKER_JOB_ENRICHMENT_ENABLED=false
@@ -3,6 +3,7 @@ using System.Net;
using System.Text; using System.Text;
using JobTrackerApi.Controllers; using JobTrackerApi.Controllers;
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -25,7 +26,7 @@ public sealed class JobDiscoveryControllerTests
var result = await controller.Search("backend", "oslo", CancellationToken.None); var result = await controller.Search("backend", "oslo", CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value)); var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<DiscoveredJob>>(ok.Value));
Assert.Equal("Backend Developer", job.Title); Assert.Equal("Backend Developer", job.Title);
Assert.Equal("nav", job.Source); Assert.Equal("nav", job.Source);
Assert.Equal("NAV Arbeidsplassen", job.SourceName); Assert.Equal("NAV Arbeidsplassen", job.SourceName);
@@ -46,7 +47,7 @@ public sealed class JobDiscoveryControllerTests
var result = await controller.Search(null, null, CancellationToken.None); var result = await controller.Search(null, null, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value)); var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<DiscoveredJob>>(ok.Value));
Assert.Equal("1", job.Id); Assert.Equal("1", job.Id);
Assert.Equal("Current title", job.Title); Assert.Equal("Current title", job.Title);
} }
@@ -71,6 +72,8 @@ public sealed class JobDiscoveryControllerTests
Assert.IsType<NoContentResult>(await controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default)); Assert.IsType<NoContentResult>(await controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default));
var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value); var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
Assert.Equal(1, Assert.Single(listed).DismissedCount); Assert.Equal(1, Assert.Single(listed).DismissedCount);
var paused = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<OkObjectResult>((await controller.UpdateSavedSearch(created.Id, new(false), default)).Result).Value);
Assert.False(paused.IsActive);
} }
private sealed class ClientFactory(HttpClient client) : IHttpClientFactory private sealed class ClientFactory(HttpClient client) : IHttpClientFactory
@@ -0,0 +1,98 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class SavedJobSearchAlertTests
{
[Fact]
public async Task Worker_establishes_baseline_then_notifies_only_for_later_results()
{
var clock = new MutableTimeProvider(new DateTimeOffset(2026, 8, 31, 8, 0, 0, TimeSpan.Zero));
var discovery = new FakeDiscovery(clock, "job-1");
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Workers:SavedJobSearchAlertsEnabled"] = "true",
["JobDiscovery:SavedSearchIntervalHours"] = "6"
}).Build();
var services = new ServiceCollection();
var databaseName = Guid.NewGuid().ToString();
services.AddLogging();
services.AddHttpContextAccessor();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton<TimeProvider>(clock);
services.AddSingleton<IJobDiscoverySearchService>(discovery);
services.AddScoped<CurrentUserService>();
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
services.AddDbContext<JobTrackerContext>((_, options) => options.UseInMemoryDatabase(databaseName));
services.AddScoped<SavedJobSearchRunner>();
services.AddSingleton<BackgroundTenantRunner>();
await using var provider = services.BuildServiceProvider();
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
db.SavedJobSearches.Add(new SavedJobSearch
{
OwnerUserId = "owner-without-jobs",
Name = "Backend Oslo",
Query = "backend",
Location = "Oslo",
CreatedAtUtc = clock.GetUtcNow(),
UpdatedAtUtc = clock.GetUtcNow()
});
await db.SaveChangesAsync();
}
var worker = new SavedJobSearchAlertHostedService(
provider.GetRequiredService<BackgroundTenantRunner>(),
configuration,
NullLogger<SavedJobSearchAlertHostedService>.Instance,
Mock.Of<IStartupReadiness>(),
clock);
Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default));
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
Assert.Empty(await db.UserNotifications.IgnoreQueryFilters().ToListAsync());
Assert.Single(await db.SavedJobSearchResults.IgnoreQueryFilters().ToListAsync());
}
clock.SetUtcNow(clock.GetUtcNow().AddHours(7));
discovery.Ids.Add("job-2");
Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default));
await using var verification = provider.CreateAsyncScope();
var notification = Assert.Single(await verification.ServiceProvider.GetRequiredService<JobTrackerContext>()
.UserNotifications.IgnoreQueryFilters().AsNoTracking().ToListAsync());
Assert.Equal("owner-without-jobs", notification.OwnerUserId);
Assert.Equal("saved_search_results", notification.Kind);
Assert.Equal("/discover", notification.LinkPath);
Assert.Contains("Backend Oslo", notification.Message);
}
private sealed class FakeDiscovery(MutableTimeProvider clock, params string[] ids) : IJobDiscoverySearchService
{
public List<string> Ids { get; } = [.. ids];
public Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<DiscoveredJob>>(Ids.Select(id => new DiscoveredJob(
id, "Backend Developer", "Acme", "Oslo", clock.GetUtcNow(), null,
$"https://example.test/{id}", "nav", "NAV Arbeidsplassen", "searched", clock.GetUtcNow(), "NO")).ToList());
}
private sealed class MutableTimeProvider(DateTimeOffset now) : TimeProvider
{
private DateTimeOffset _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void SetUtcNow(DateTimeOffset value) => _now = value;
}
}
@@ -1,4 +1,3 @@
using System.Net.Http.Headers;
using System.Text.Json; using System.Text.Json;
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Models; using JobTrackerApi.Models;
@@ -15,10 +14,8 @@ namespace JobTrackerApi.Controllers;
[Authorize(AuthenticationSchemes = "local")] [Authorize(AuthenticationSchemes = "local")]
public sealed class JobDiscoveryController : ControllerBase public sealed class JobDiscoveryController : ControllerBase
{ {
private const string BaseUrl = "https://pam-stilling-feed.nav.no"; private readonly IJobDiscoverySearchService _discovery;
private readonly IHttpClientFactory _clients; private readonly SavedJobSearchRunner? _savedSearchRunner;
private readonly IConfiguration _configuration;
private readonly IMemoryCache _cache;
private readonly JobTrackerContext? _db; private readonly JobTrackerContext? _db;
private readonly ICurrentUserService? _currentUser; private readonly ICurrentUserService? _currentUser;
@@ -27,11 +24,12 @@ public sealed class JobDiscoveryController : ControllerBase
IConfiguration configuration, IConfiguration configuration,
IMemoryCache cache, IMemoryCache cache,
JobTrackerContext? db = null, JobTrackerContext? db = null,
ICurrentUserService? currentUser = null) ICurrentUserService? currentUser = null,
IJobDiscoverySearchService? discovery = null,
SavedJobSearchRunner? savedSearchRunner = null)
{ {
_clients = clients; _discovery = discovery ?? new NavJobDiscoverySearchService(clients, configuration, cache, TimeProvider.System);
_configuration = configuration; _savedSearchRunner = savedSearchRunner;
_cache = cache;
_db = db; _db = db;
_currentUser = currentUser; _currentUser = currentUser;
} }
@@ -41,7 +39,7 @@ public sealed class JobDiscoveryController : ControllerBase
{ {
try 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) catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
{ {
@@ -86,6 +84,19 @@ public sealed class JobDiscoveryController : ControllerBase
return NoContent(); 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")] [HttpPost("saved-searches/{id:int}/run")]
public async Task<ActionResult<SavedSearchRunDto>> RunSavedSearch(int id, CancellationToken cancellationToken) public async Task<ActionResult<SavedSearchRunDto>> RunSavedSearch(int id, CancellationToken cancellationToken)
{ {
@@ -95,26 +106,10 @@ public sealed class JobDiscoveryController : ControllerBase
try try
{ {
var jobs = await SearchCoreAsync(search.Query, search.Location, cancellationToken); var runner = _savedSearchRunner ?? new SavedJobSearchRunner(db, _discovery, TimeProvider.System);
var now = DateTimeOffset.UtcNow; var run = await runner.RunAsync(search, cancellationToken);
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;
await db.SaveChangesAsync(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) 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; 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 SavedSearchRequest(string? Name, string? Query, string? Location);
public sealed record UpdateSavedSearchRequest(bool IsActive);
public sealed record DismissRequest(bool IsDismissed); 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 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); 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<IAiOperationHandler, CvProcessingOperationHandler>();
builder.Services.AddSingleton<AiOperationWorker>(); builder.Services.AddSingleton<AiOperationWorker>();
builder.Services.AddScoped<UserNotificationStore>(); builder.Services.AddScoped<UserNotificationStore>();
builder.Services.AddSingleton<IJobDiscoverySearchService, NavJobDiscoverySearchService>();
builder.Services.AddScoped<SavedJobSearchRunner>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>(); builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>(); builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>(); builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
@@ -174,6 +176,7 @@ builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>
builder.Services.AddHostedService<DatabaseBackupHostedService>(); builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>(); builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>(); builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<SavedJobSearchAlertHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>(); builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>(); builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>(); builder.Services.AddHostedService<SummarizerProbeHostedService>();
@@ -16,13 +16,28 @@ public sealed class BackgroundTenantRunner(
string worker, string worker,
Func<IServiceProvider, CancellationToken, Task> work, Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken) 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(); await using var enumerationScope = scopes.CreateAsyncScope();
var ownerIds = await enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>() var ownerIds = await findOwners(enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>());
.JobApplications.IgnoreQueryFilters().AsNoTracking()
.Where(job => job.OwnerUserId != null)
.Select(job => job.OwnerUserId!)
.ToListAsync(cancellationToken);
var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner)) var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner))
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.Order(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": { "Workers": {
"RulesEnabled": false, "RulesEnabled": false,
"FollowUpRemindersEnabled": false, "FollowUpRemindersEnabled": false,
"SavedJobSearchAlertsEnabled": true,
"DailyExportEnabled": false, "DailyExportEnabled": false,
"JobEnrichmentEnabled": false, "JobEnrichmentEnabled": false,
"AiOperationsEnabled": false "AiOperationsEnabled": false
}, },
"JobDiscovery": {
"SavedSearchIntervalHours": 6
},
"Ai": { "Ai": {
"ExternalProcessingEnabled": false, "ExternalProcessingEnabled": false,
"ExternalProvider": "ollama", "ExternalProvider": "ollama",
+2
View File
@@ -78,6 +78,8 @@ services:
# notification/privacy/entitlement prerequisites have been explicitly rolled out. # notification/privacy/entitlement prerequisites have been explicitly rolled out.
- Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false} - Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false}
- Workers__FollowUpRemindersEnabled=${WORKER_FOLLOWUP_REMINDERS_ENABLED:-false} - Workers__FollowUpRemindersEnabled=${WORKER_FOLLOWUP_REMINDERS_ENABLED:-false}
- Workers__SavedJobSearchAlertsEnabled=${WORKER_SAVED_SEARCH_ALERTS_ENABLED:-true}
- JobDiscovery__SavedSearchIntervalHours=${SAVED_SEARCH_INTERVAL_HOURS:-6}
- Workers__DailyExportEnabled=${WORKER_DAILY_EXPORT_ENABLED:-false} - Workers__DailyExportEnabled=${WORKER_DAILY_EXPORT_ENABLED:-false}
- Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false} - Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
- Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_ENABLED:-false} - Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_ENABLED:-false}
+2 -1
View File
@@ -6,7 +6,7 @@ Updated: 2026-08-02
HTTP requests derive `JobTrackerContext.CurrentUserId` from the authenticated request. Hosted services have no HTTP context, so deny-on-null query filters intentionally return no tenant rows. HTTP requests derive `JobTrackerContext.CurrentUserId` from the authenticated request. Hosted services have no HTTP context, so deny-on-null query filters intentionally return no tenant rows.
`BackgroundTenantRunner` is the only worker bypass for the four job-owner schedulers. It uses `IgnoreQueryFilters` only to enumerate distinct non-empty job owners, then creates a fresh scope per owner and sets `CurrentUserService` before resolving the scoped `JobTrackerContext`. All work queries run through the normal tenant filters. An owner failure is counted and isolated; logs contain worker name, exception type and aggregate counts, not owner IDs or private content. An HTTP context cannot be replaced by a background owner. `BackgroundTenantRunner` is the only worker bypass for the tenant schedulers. It uses `IgnoreQueryFilters` only to enumerate distinct non-empty owners from the resource a worker owns, then creates a fresh scope per owner and sets `CurrentUserService` before resolving the scoped `JobTrackerContext`. All work queries run through the normal tenant filters. An owner failure is counted and isolated; logs contain worker name, exception type and aggregate counts, not owner IDs or private content. An HTTP context cannot be replaced by a background owner.
This is a sequential, single-instance foundation. Generic operation leasing now exists in OPS-001A, but notifications, bounded AI handlers and multi-replica scheduling belong to OPS-001B/C and AI-001 and must precede activation that needs them. This is a sequential, single-instance foundation. Generic operation leasing now exists in OPS-001A, but notifications, bounded AI handlers and multi-replica scheduling belong to OPS-001B/C and AI-001 and must precede activation that needs them.
@@ -16,6 +16,7 @@ This is a sequential, single-instance foundation. Generic operation leasing now
|---|---|---|---| |---|---|---|---|
| `RulesHostedService` | owner runner + normal filters/per-user rules | changes job status | `Workers:RulesEnabled=false` by default; keep off until user-visible notification/audit behavior is ready | | `RulesHostedService` | owner runner + normal filters/per-user rules | changes job status | `Workers:RulesEnabled=false` by default; keep off until user-visible notification/audit behavior is ready |
| `FollowUpReminderHostedService` | owner runner + normal filters | sends email, then marks date | both `Workers:FollowUpRemindersEnabled` and `Email:FollowUpReminders:Enabled`; keep off until persistent notification/idempotency work | | `FollowUpReminderHostedService` | owner runner + normal filters | sends email, then marks date | both `Workers:FollowUpRemindersEnabled` and `Email:FollowUpReminders:Enabled`; keep off until persistent notification/idempotency work |
| `SavedJobSearchAlertHostedService` | saved-search owner runner + normal filters | checks active NAV searches and creates in-app notifications | enabled by default; users can pause each search, interval is `JobDiscovery:SavedSearchIntervalHours` (default 6); first run establishes a silent baseline |
| `DailyExportHostedService` | owner runner + normal filters; one hashed-owner atomic file | writes local JSON | both `Workers:DailyExportEnabled` and `Exports:DailyEnabled`; keep off pending retention/operator rollout | | `DailyExportHostedService` | owner runner + normal filters; one hashed-owner atomic file | writes local JSON | both `Workers:DailyExportEnabled` and `Exports:DailyEnabled`; keep off pending retention/operator rollout |
| `JobEnrichmentHostedService` | owner runner + normal filters | deterministic tags and AI summary | `Workers:JobEnrichmentEnabled=false`; do not enable before Pro/privacy/provider/queue gates | | `JobEnrichmentHostedService` | owner runner + normal filters | deterministic tags and AI summary | `Workers:JobEnrichmentEnabled=false`; do not enable before Pro/privacy/provider/queue gates |
| `CvProcessingHostedService` | existing explicit run owner on every unfiltered query | user-requested CV parsing/AI | unchanged; persistent run rows recover at startup, process-local wake-up remains single-instance | | `CvProcessingHostedService` | existing explicit run owner on every unfiltered query | user-requested CV parsing/AI | unchanged; persistent run rows recover at startup, process-local wake-up remains single-instance |
+6 -2
View File
@@ -275,7 +275,9 @@ export const translations = {
jobDiscoveryViewListing: "View listing", jobDiscoveryViewListing: "View listing",
jobDiscoverySaveTracker: "Save to tracker", jobDiscoverySaveTracker: "Save to tracker",
jobDiscoverySavedSearches: "Saved searches", jobDiscoverySavedSearches: "Saved searches",
jobDiscoverySavedSearchesHelp: "Run a saved NAV search to see which vacancies are genuinely new since your last check.", jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
jobDiscoveryAutomaticAlerts: "Automatic alerts",
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
jobDiscoverySavedName: "Search name", jobDiscoverySavedName: "Search name",
jobDiscoverySavedDefaultName: "Recent vacancies", jobDiscoverySavedDefaultName: "Recent vacancies",
jobDiscoverySaveSearch: "Save search", jobDiscoverySaveSearch: "Save search",
@@ -2675,7 +2677,9 @@ export const translations = {
jobDiscoveryViewListing: "Vis stilling", jobDiscoveryViewListing: "Vis stilling",
jobDiscoverySaveTracker: "Lagre i oversikten", jobDiscoverySaveTracker: "Lagre i oversikten",
jobDiscoverySavedSearches: "Lagrede søk", jobDiscoverySavedSearches: "Lagrede søk",
jobDiscoverySavedSearchesHelp: "Kjør et lagret NAV-søk for å se hvilke stillinger som faktisk er nye siden sist.", jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
jobDiscoverySavedName: "Navn på søket", jobDiscoverySavedName: "Navn på søket",
jobDiscoverySavedDefaultName: "Nylige stillinger", jobDiscoverySavedDefaultName: "Nylige stillinger",
jobDiscoverySaveSearch: "Lagre søk", jobDiscoverySaveSearch: "Lagre søk",
+14
View File
@@ -93,3 +93,17 @@ test("runs a saved search, marks unseen vacancies, and dismisses them", async ()
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true }); expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
}); });
test("lets the user pause automatic alerts for a saved search", async () => {
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 1, dismissedCount: 0 };
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
mockedApi.patch.mockResolvedValue({ data: { ...saved, isActive: false } } as any);
renderPage();
const toggle = await screen.findByRole("switch", { name: "Automatic alerts" });
expect(toggle).toBeChecked();
fireEvent.click(toggle);
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7", { isActive: false }));
await waitFor(() => expect(toggle).not.toBeChecked());
});
+17 -2
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, MenuItem, Stack, TextField, Typography } from "@mui/material"; import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined"; import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
@@ -26,7 +26,7 @@ type DiscoveredJob = {
isNew?: boolean; isNew?: boolean;
}; };
type SavedSearch = { id: number; name: string; query: string; location: string; lastRunAtUtc?: string; resultCount: number; dismissedCount: number }; type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; lastRunAtUtc?: string; resultCount: number; dismissedCount: number };
type SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> }; type SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> };
type SortOrder = "updated" | "deadline" | "title"; type SortOrder = "updated" | "deadline" | "title";
@@ -47,6 +47,7 @@ export default function JobDiscoveryPage() {
const [savedName, setSavedName] = useState(""); const [savedName, setSavedName] = useState("");
const [savingSearch, setSavingSearch] = useState(false); const [savingSearch, setSavingSearch] = useState(false);
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null); const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -102,6 +103,15 @@ export default function JobDiscoveryPage() {
} catch { setError(t("jobDiscoveryDeleteSavedFailed")); } } catch { setError(t("jobDiscoveryDeleteSavedFailed")); }
}; };
const toggleSavedSearchAlerts = async (saved: SavedSearch) => {
setUpdatingSavedSearchId(saved.id); setError("");
try {
const response = await api.patch<SavedSearch>(`/job-discovery/saved-searches/${saved.id}`, { isActive: !saved.isActive });
setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data : item));
} catch { setError(t("jobDiscoveryUpdateAlertsFailed")); }
finally { setUpdatingSavedSearchId(null); }
};
const dismissJob = async (jobId: string) => { const dismissJob = async (jobId: string) => {
if (activeSavedSearchId === null) return; if (activeSavedSearchId === null) return;
try { try {
@@ -148,6 +158,11 @@ export default function JobDiscoveryPage() {
<Typography variant="caption" color="text.secondary">{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography> <Typography variant="caption" color="text.secondary">{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography>
</Box> </Box>
<Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} /> <Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} />
<FormControlLabel
sx={{ m: 0 }}
control={<Switch size="small" checked={saved.isActive} disabled={updatingSavedSearchId === saved.id} onChange={() => void toggleSavedSearchAlerts(saved)} />}
label={<Typography variant="body2">{t("jobDiscoveryAutomaticAlerts")}</Typography>}
/>
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={loading}>{t("jobDiscoveryRunSaved")}</Button> <Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={loading}>{t("jobDiscoveryRunSaved")}</Button>
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button> <Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
</Box> </Box>