From b7029faaec43ed64a3d16f42ff2771bb6fd29389 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 22:12:52 +0200 Subject: [PATCH] feat(discovery): schedule saved-search alerts --- .env.example | 2 + .../JobDiscoveryControllerTests.cs | 7 +- .../SavedJobSearchAlertTests.cs | 98 ++++++++++++++ .../Controllers/JobDiscoveryController.cs | 126 ++++-------------- JobTrackerApi/Models/DiscoveredJob.cs | 15 +++ JobTrackerApi/Program.cs | 3 + .../Services/BackgroundTenantRunner.cs | 25 +++- .../Services/JobDiscoverySearchService.cs | 80 +++++++++++ .../SavedJobSearchAlertHostedService.cs | 86 ++++++++++++ .../Services/SavedJobSearchRunner.cs | 44 ++++++ JobTrackerApi/appsettings.json | 4 + docker-compose.yml | 2 + docs/architecture/background-workers.md | 3 +- job-tracker-ui/src/i18n/translations.ts | 8 +- job-tracker-ui/src/job-discovery.test.tsx | 14 ++ job-tracker-ui/src/views/JobDiscoveryPage.tsx | 19 ++- 16 files changed, 423 insertions(+), 113 deletions(-) create mode 100644 JobTrackerApi.Tests/SavedJobSearchAlertTests.cs create mode 100644 JobTrackerApi/Models/DiscoveredJob.cs create mode 100644 JobTrackerApi/Services/JobDiscoverySearchService.cs create mode 100644 JobTrackerApi/Services/SavedJobSearchAlertHostedService.cs create mode 100644 JobTrackerApi/Services/SavedJobSearchRunner.cs diff --git a/.env.example b/.env.example index 914a443..e2853bd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs b/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs index aa3b2a7..261511d 100644 --- a/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs +++ b/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs @@ -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(result.Result); - var job = Assert.Single(Assert.IsAssignableFrom>(ok.Value)); + var job = Assert.Single(Assert.IsAssignableFrom>(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(result.Result); - var job = Assert.Single(Assert.IsAssignableFrom>(ok.Value)); + var job = Assert.Single(Assert.IsAssignableFrom>(ok.Value)); Assert.Equal("1", job.Id); Assert.Equal("Current title", job.Title); } @@ -71,6 +72,8 @@ public sealed class JobDiscoveryControllerTests Assert.IsType(await controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default)); var listed = Assert.IsAssignableFrom>(Assert.IsType((await controller.ListSavedSearches(default)).Result).Value); Assert.Equal(1, Assert.Single(listed).DismissedCount); + var paused = Assert.IsType(Assert.IsType((await controller.UpdateSavedSearch(created.Id, new(false), default)).Result).Value); + Assert.False(paused.IsActive); } private sealed class ClientFactory(HttpClient client) : IHttpClientFactory diff --git a/JobTrackerApi.Tests/SavedJobSearchAlertTests.cs b/JobTrackerApi.Tests/SavedJobSearchAlertTests.cs new file mode 100644 index 0000000..91b7ce2 --- /dev/null +++ b/JobTrackerApi.Tests/SavedJobSearchAlertTests.cs @@ -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 + { + ["Workers:SavedJobSearchAlertsEnabled"] = "true", + ["JobDiscovery:SavedSearchIntervalHours"] = "6" + }).Build(); + var services = new ServiceCollection(); + var databaseName = Guid.NewGuid().ToString(); + services.AddLogging(); + services.AddHttpContextAccessor(); + services.AddSingleton(configuration); + services.AddSingleton(clock); + services.AddSingleton(discovery); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddDbContext((_, options) => options.UseInMemoryDatabase(databaseName)); + services.AddScoped(); + services.AddSingleton(); + await using var provider = services.BuildServiceProvider(); + + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + 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(), + configuration, + NullLogger.Instance, + Mock.Of(), + clock); + + Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default)); + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + 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() + .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 Ids { get; } = [.. ids]; + + public Task> SearchAsync(string? query, string? location, CancellationToken cancellationToken) => + Task.FromResult>(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; + } +} diff --git a/JobTrackerApi/Controllers/JobDiscoveryController.cs b/JobTrackerApi/Controllers/JobDiscoveryController.cs index f1922ba..a0dd39d 100644 --- a/JobTrackerApi/Controllers/JobDiscoveryController.cs +++ b/JobTrackerApi/Controllers/JobDiscoveryController.cs @@ -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> 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> 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(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> SearchCoreAsync(string? q, string? location, CancellationToken cancellationToken) - { - var retrievedAt = DateTimeOffset.UtcNow; - var token = await GetTokenAsync(cancellationToken); - var client = _clients.CreateClient(); - var entries = new Dictionary(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 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); diff --git a/JobTrackerApi/Models/DiscoveredJob.cs b/JobTrackerApi/Models/DiscoveredJob.cs new file mode 100644 index 0000000..7a4d46e --- /dev/null +++ b/JobTrackerApi/Models/DiscoveredJob.cs @@ -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); diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index fdf7742..5e5e27e 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -60,6 +60,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -174,6 +176,7 @@ builder.Services.AddSingleton builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/JobTrackerApi/Services/BackgroundTenantRunner.cs b/JobTrackerApi/Services/BackgroundTenantRunner.cs index 95d28cd..d047d90 100644 --- a/JobTrackerApi/Services/BackgroundTenantRunner.cs +++ b/JobTrackerApi/Services/BackgroundTenantRunner.cs @@ -16,13 +16,28 @@ public sealed class BackgroundTenantRunner( string worker, Func 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 RunForSavedSearchOwnersAsync( + string worker, + Func 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 RunForOwnersAsync( + string worker, + Func>> findOwners, + Func work, + CancellationToken cancellationToken) { await using var enumerationScope = scopes.CreateAsyncScope(); - var ownerIds = await enumerationScope.ServiceProvider.GetRequiredService() - .JobApplications.IgnoreQueryFilters().AsNoTracking() - .Where(job => job.OwnerUserId != null) - .Select(job => job.OwnerUserId!) - .ToListAsync(cancellationToken); + var ownerIds = await findOwners(enumerationScope.ServiceProvider.GetRequiredService()); var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner)) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) diff --git a/JobTrackerApi/Services/JobDiscoverySearchService.cs b/JobTrackerApi/Services/JobDiscoverySearchService.cs new file mode 100644 index 0000000..c985f90 --- /dev/null +++ b/JobTrackerApi/Services/JobDiscoverySearchService.cs @@ -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> 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> SearchAsync(string? query, string? location, CancellationToken cancellationToken) + { + var retrievedAt = timeProvider.GetUtcNow(); + var token = await GetTokenAsync(cancellationToken); + var client = clients.CreateClient(); + var entries = new Dictionary(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 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); +} diff --git a/JobTrackerApi/Services/SavedJobSearchAlertHostedService.cs b/JobTrackerApi/Services/SavedJobSearchAlertHostedService.cs new file mode 100644 index 0000000..3c158c6 --- /dev/null +++ b/JobTrackerApi/Services/SavedJobSearchAlertHostedService.cs @@ -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 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 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(); + var runner = services.GetRequiredService(); + 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); + } +} diff --git a/JobTrackerApi/Services/SavedJobSearchRunner.cs b/JobTrackerApi/Services/SavedJobSearchRunner.cs new file mode 100644 index 0000000..d094036 --- /dev/null +++ b/JobTrackerApi/Services/SavedJobSearchRunner.cs @@ -0,0 +1,44 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +public sealed record SavedJobSearchRunResult( + IReadOnlyList 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 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(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); + } +} diff --git a/JobTrackerApi/appsettings.json b/JobTrackerApi/appsettings.json index 23d7bff..4b78ab5 100644 --- a/JobTrackerApi/appsettings.json +++ b/JobTrackerApi/appsettings.json @@ -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", diff --git a/docker-compose.yml b/docker-compose.yml index 84187da..7b8bca6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/architecture/background-workers.md b/docs/architecture/background-workers.md index 326f349..41054d9 100644 --- a/docs/architecture/background-workers.md +++ b/docs/architecture/background-workers.md @@ -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 | diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index b789106..3ec623d 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx index dddf439..55cb6d6 100644 --- a/job-tracker-ui/src/job-discovery.test.tsx +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -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()); +}); diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx index 220facd..bacc411 100644 --- a/job-tracker-ui/src/views/JobDiscoveryPage.tsx +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -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(null); + const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState(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(`/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() { {[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""} + void toggleSavedSearchAlerts(saved)} />} + label={{t("jobDiscoveryAutomaticAlerts")}} + />