feat(discovery): schedule saved-search alerts
This commit is contained in:
@@ -104,6 +104,8 @@ EMAIL_FOLLOWUPREMINDERS_ENABLED=false
|
||||
EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2
|
||||
WORKER_RULES_ENABLED=false
|
||||
WORKER_FOLLOWUP_REMINDERS_ENABLED=false
|
||||
WORKER_SAVED_SEARCH_ALERTS_ENABLED=true
|
||||
SAVED_SEARCH_INTERVAL_HOURS=6
|
||||
WORKER_DAILY_EXPORT_ENABLED=false
|
||||
WORKER_JOB_ENRICHMENT_ENABLED=false
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -25,7 +26,7 @@ public sealed class JobDiscoveryControllerTests
|
||||
var result = await controller.Search("backend", "oslo", CancellationToken.None);
|
||||
|
||||
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("nav", job.Source);
|
||||
Assert.Equal("NAV Arbeidsplassen", job.SourceName);
|
||||
@@ -46,7 +47,7 @@ public sealed class JobDiscoveryControllerTests
|
||||
var result = await controller.Search(null, null, CancellationToken.None);
|
||||
|
||||
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("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));
|
||||
var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
|
||||
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
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -78,6 +78,8 @@ services:
|
||||
# notification/privacy/entitlement prerequisites have been explicitly rolled out.
|
||||
- Workers__RulesEnabled=${WORKER_RULES_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__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
|
||||
- Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_ENABLED:-false}
|
||||
|
||||
@@ -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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -275,7 +275,9 @@ export const translations = {
|
||||
jobDiscoveryViewListing: "View listing",
|
||||
jobDiscoverySaveTracker: "Save to tracker",
|
||||
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",
|
||||
jobDiscoverySavedDefaultName: "Recent vacancies",
|
||||
jobDiscoverySaveSearch: "Save search",
|
||||
@@ -2675,7 +2677,9 @@ export const translations = {
|
||||
jobDiscoveryViewListing: "Vis stilling",
|
||||
jobDiscoverySaveTracker: "Lagre i oversikten",
|
||||
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",
|
||||
jobDiscoverySavedDefaultName: "Nylige stillinger",
|
||||
jobDiscoverySaveSearch: "Lagre søk",
|
||||
|
||||
@@ -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());
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 AddIcon from "@mui/icons-material/Add";
|
||||
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
|
||||
@@ -26,7 +26,7 @@ type DiscoveredJob = {
|
||||
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 SortOrder = "updated" | "deadline" | "title";
|
||||
@@ -47,6 +47,7 @@ export default function JobDiscoveryPage() {
|
||||
const [savedName, setSavedName] = useState("");
|
||||
const [savingSearch, setSavingSearch] = useState(false);
|
||||
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
||||
const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -102,6 +103,15 @@ export default function JobDiscoveryPage() {
|
||||
} 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) => {
|
||||
if (activeSavedSearchId === null) return;
|
||||
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>
|
||||
</Box>
|
||||
<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" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user