Compare commits

...

3 Commits

Author SHA1 Message Date
cesnimda caf486dceb feat(capture): add minimal browser extension 2026-08-31 22:23:34 +02:00
cesnimda b7029faaec feat(discovery): schedule saved-search alerts 2026-08-31 22:23:34 +02:00
cesnimda 19a89018a0 fix(ci): retry corrupt parser downloads
CI and Deploy / test (push) Failing after 8m37s
CI and Deploy / deploy (push) Has been skipped
2026-08-31 22:23:32 +02:00
22 changed files with 529 additions and 118 deletions
+2
View File
@@ -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
+11 -5
View File
@@ -73,12 +73,18 @@ jobs:
# The Debian runner omits pip and python3-venv. Use pip's official
# zipapp and install into the checkout so CI remains isolated without
# sudo or a mutable runner-global Python environment.
rm -rf .python-ci
curl -fsSLo /tmp/pip.pyz https://bootstrap.pypa.io/pip/pip.pyz
python3 /tmp/pip.pyz install --target .python-ci --require-hashes \
--extra-index-url https://download.pytorch.org/whl/cpu \
-r requirements-linux.lock
python3 /tmp/pip.pyz install --target .python-ci pytest==8.3.5 httpx2==2.12.0
install_parser_dependencies() {
rm -rf .python-ci
parser_pip_cache="$(mktemp -d)"
PIP_CACHE_DIR="$parser_pip_cache" python3 /tmp/pip.pyz install --target .python-ci --require-hashes \
--extra-index-url https://download.pytorch.org/whl/cpu \
-r requirements-linux.lock
}
install_parser_dependencies \
|| ( echo "Parser dependency install failed ($?) — retrying with a fresh isolated cache..." \
&& install_parser_dependencies )
PIP_CACHE_DIR="$parser_pip_cache" python3 /tmp/pip.pyz install --upgrade --target .python-ci pytest==8.3.5 httpx2==2.12.0
PYTHONPATH="$PWD/.python-ci" AI_SERVICE_SKIP_MODEL_LOAD=1 python3 -m pytest -q
- name: Restore backend
@@ -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);
+15
View File
@@ -0,0 +1,15 @@
namespace JobTrackerApi.Models;
public sealed record DiscoveredJob(
string Id,
string Title,
string? Company,
string? Location,
DateTimeOffset? ModifiedAt,
DateTimeOffset? Deadline,
string Url,
string Source,
string SourceName,
string AcquisitionType,
DateTimeOffset RetrievedAt,
string CountryCode);
+3
View File
@@ -60,6 +60,8 @@ builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHand
builder.Services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
builder.Services.AddSingleton<AiOperationWorker>();
builder.Services.AddScoped<UserNotificationStore>();
builder.Services.AddSingleton<IJobDiscoverySearchService, NavJobDiscoverySearchService>();
builder.Services.AddScoped<SavedJobSearchRunner>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
@@ -174,6 +176,7 @@ builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>
builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<SavedJobSearchAlertHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
@@ -16,13 +16,28 @@ public sealed class BackgroundTenantRunner(
string worker,
Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken)
{
return await RunForOwnersAsync(worker, async db => await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
.Where(job => job.OwnerUserId != null).Select(job => job.OwnerUserId!).ToListAsync(cancellationToken), work, cancellationToken);
}
public async Task<BackgroundWorkerRunResult> RunForSavedSearchOwnersAsync(
string worker,
Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken)
{
return await RunForOwnersAsync(worker, async db => await db.SavedJobSearches.IgnoreQueryFilters().AsNoTracking()
.Where(search => search.IsActive).Select(search => search.OwnerUserId).ToListAsync(cancellationToken), work, cancellationToken);
}
private async Task<BackgroundWorkerRunResult> RunForOwnersAsync(
string worker,
Func<JobTrackerContext, Task<List<string>>> findOwners,
Func<IServiceProvider, CancellationToken, Task> work,
CancellationToken cancellationToken)
{
await using var enumerationScope = scopes.CreateAsyncScope();
var ownerIds = await enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>()
.JobApplications.IgnoreQueryFilters().AsNoTracking()
.Where(job => job.OwnerUserId != null)
.Select(job => job.OwnerUserId!)
.ToListAsync(cancellationToken);
var ownerIds = await findOwners(enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>());
var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner))
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using System.Text.Json;
using JobTrackerApi.Models;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public interface IJobDiscoverySearchService
{
Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken);
}
public sealed class NavJobDiscoverySearchService(
IHttpClientFactory clients,
IConfiguration configuration,
IMemoryCache cache,
TimeProvider timeProvider) : IJobDiscoverySearchService
{
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
public async Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken)
{
var retrievedAt = timeProvider.GetUtcNow();
var token = await GetTokenAsync(cancellationToken);
var client = clients.CreateClient();
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
var next = "/api/v1/feed";
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (page == 0) request.Headers.IfModifiedSince = timeProvider.GetUtcNow().AddDays(-14);
using var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
{
var feed = item.GetProperty("_feed_entry");
var jobId = feed.GetProperty("uuid").GetString();
if (string.IsNullOrWhiteSpace(jobId)) continue;
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) { entries.Remove(jobId); continue; }
entries[jobId] = new DiscoveredJob(jobId,
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
feed.TryGetProperty("applicationDue", out var due) && due.TryGetDateTimeOffset(out var deadline) ? deadline : null,
$"https://arbeidsplassen.nav.no/stillinger/stilling/{jobId}", "nav", "NAV Arbeidsplassen", "searched", retrievedAt, "NO");
}
}
var term = (query ?? "").Trim();
var place = (location ?? "").Trim();
return entries.Values.Where(job => Contains(job.Title, term) || Contains(job.Company, term))
.Where(job => Contains(job.Location, place)).OrderByDescending(job => job.ModifiedAt).Take(100).ToList();
}
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
{
var configured = configuration["NavJobs:Token"]?.Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
return await cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
var text = await clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
var token = text[start..].Trim();
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
return end < 0 ? token : token[..end];
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
}
private static bool Contains(string? value, string filter) =>
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
}
@@ -0,0 +1,86 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed class SavedJobSearchAlertHostedService(
BackgroundTenantRunner tenants,
IConfiguration configuration,
ILogger<SavedJobSearchAlertHostedService> logger,
IStartupReadiness startupReadiness,
TimeProvider timeProvider) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
if (!IsEnabled())
{
logger.LogInformation("Saved job search alert worker disabled.");
return;
}
await Task.Delay(TimeSpan.FromSeconds(30), timeProvider, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
await RunOnceAsync(stoppingToken);
await Task.Delay(GetInterval(), timeProvider, stoppingToken);
}
}
public Task<BackgroundWorkerRunResult> RunOnceAsync(CancellationToken cancellationToken) =>
IsEnabled()
? tenants.RunForSavedSearchOwnersAsync("saved-job-search-alerts", ProcessOwnerAsync, cancellationToken)
: Task.FromResult(BackgroundWorkerRunResult.Disabled);
private bool IsEnabled() => configuration.GetValue("Workers:SavedJobSearchAlertsEnabled", true);
private TimeSpan GetInterval() => TimeSpan.FromHours(Math.Clamp(
configuration.GetValue("JobDiscovery:SavedSearchIntervalHours", 6), 1, 24));
private async Task ProcessOwnerAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var db = services.GetRequiredService<JobTrackerContext>();
var runner = services.GetRequiredService<SavedJobSearchRunner>();
var now = timeProvider.GetUtcNow();
var dueBefore = now - GetInterval();
var useNorwegian = string.Equals(
await db.Users.AsNoTracking().Where(user => user.Id == db.CurrentUserId).Select(user => user.UiLanguage).FirstOrDefaultAsync(cancellationToken),
"nb",
StringComparison.OrdinalIgnoreCase);
var searches = await db.SavedJobSearches.Include(search => search.Results)
.Where(search => search.IsActive)
.OrderBy(search => search.Id)
.ToListAsync(cancellationToken);
foreach (var search in searches.Where(search => search.LastRunAtUtc is null || search.LastRunAtUtc <= dueBefore))
{
var result = await runner.RunAsync(search, cancellationToken);
// The first automatic run establishes a baseline. Alert only for vacancies
// that appear after that baseline so existing results never look "new".
if (!result.WasInitialRun && result.NewCount > 0)
{
db.UserNotifications.Add(new UserNotification
{
Id = Guid.NewGuid(),
OwnerUserId = search.OwnerUserId,
Kind = "saved_search_results",
Title = useNorwegian
? result.NewCount == 1 ? "Ny stilling funnet" : $"{result.NewCount} nye stillinger funnet"
: result.NewCount == 1 ? "New vacancy found" : $"{result.NewCount} new vacancies found",
Message = useNorwegian
? result.NewCount == 1
? $"Det lagrede søket «{search.Name}» fant en ny stilling."
: $"Det lagrede søket «{search.Name}» fant {result.NewCount} nye stillinger."
: result.NewCount == 1
? $"Your saved search ‘{search.Name}’ found a new vacancy."
: $"Your saved search ‘{search.Name}’ found {result.NewCount} new vacancies.",
LinkPath = "/discover",
CreatedAtUtc = now.UtcDateTime
});
}
}
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,44 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record SavedJobSearchRunResult(
IReadOnlyList<SavedJobSearchJobResult> Jobs,
int NewCount,
bool WasInitialRun);
public sealed record SavedJobSearchJobResult(DiscoveredJob Job, bool IsNew, bool IsDismissed);
public sealed class SavedJobSearchRunner(
JobTrackerContext db,
IJobDiscoverySearchService discovery,
TimeProvider timeProvider)
{
public async Task<SavedJobSearchRunResult> RunAsync(SavedJobSearch search, CancellationToken cancellationToken)
{
var jobs = await discovery.SearchAsync(search.Query, search.Location, cancellationToken);
var now = timeProvider.GetUtcNow();
var wasInitialRun = search.LastRunAtUtc is null;
var existing = search.Results.ToDictionary(item => item.ExternalJobId, StringComparer.OrdinalIgnoreCase);
var results = new List<SavedJobSearchJobResult>(jobs.Count);
var newCount = 0;
foreach (var job in jobs)
{
var isNew = !existing.TryGetValue(job.Id, out var state);
if (state is null)
{
state = new SavedJobSearchResult { ExternalJobId = job.Id, FirstSeenAtUtc = now, LastSeenAtUtc = now };
search.Results.Add(state);
db.SavedJobSearchResults.Add(state);
newCount++;
}
else state.LastSeenAtUtc = now;
results.Add(new SavedJobSearchJobResult(job, isNew, state.IsDismissed));
}
search.LastRunAtUtc = now;
search.UpdatedAtUtc = now;
return new SavedJobSearchRunResult(results, newCount, wasInitialRun);
}
}
+4
View File
@@ -13,10 +13,14 @@
"Workers": {
"RulesEnabled": false,
"FollowUpRemindersEnabled": false,
"SavedJobSearchAlertsEnabled": true,
"DailyExportEnabled": false,
"JobEnrichmentEnabled": false,
"AiOperationsEnabled": false
},
"JobDiscovery": {
"SavedSearchIntervalHours": 6
},
"Ai": {
"ExternalProcessingEnabled": false,
"ExternalProvider": "ollama",
+20
View File
@@ -0,0 +1,20 @@
# Jobjakt Quick Capture
The Manifest V3 extension opens the current page or selected link in Jobjakt's existing reviewed import flow. It does not scrape page content, store credentials, or submit an application automatically.
## Install locally
1. Open `chrome://extensions` (or the equivalent extensions page in Edge).
2. Enable developer mode.
3. Choose **Load unpacked** and select this `browser-extension` folder.
4. Open a vacancy and click the Jobjakt extension, or use **Save vacancy to Jobjakt** from the page/link context menu.
The Jobjakt add-job dialog opens with the vacancy URL and runs the same server-side preview, validation, duplicate checking, and user review used by normal URL imports.
## Test
```sh
node --test browser-extension/tests/*.test.mjs
```
No build or package installation is required. Zip the folder contents for store submission after adding store artwork and completing the browser-store privacy declarations.
+23
View File
@@ -0,0 +1,23 @@
import { buildCaptureUrl } from "./capture-url.mjs";
const menuId = "save-vacancy-to-jobjakt";
function openCapture(pageUrl) {
const captureUrl = buildCaptureUrl(pageUrl);
if (captureUrl) chrome.tabs.create({ url: captureUrl });
}
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({
id: menuId,
title: "Save vacancy to Jobjakt",
contexts: ["page", "link"]
});
});
});
chrome.action.onClicked.addListener((tab) => openCapture(tab.url));
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === menuId) openCapture(info.linkUrl || info.pageUrl || tab?.url);
});
+15
View File
@@ -0,0 +1,15 @@
export const JOBBJAKT_CAPTURE_ORIGIN = "https://jobs.cesnimda.uk";
export function buildCaptureUrl(pageUrl, origin = JOBBJAKT_CAPTURE_ORIGIN) {
let source;
try {
source = new URL(pageUrl);
} catch {
return null;
}
if (source.protocol !== "http:" && source.protocol !== "https:") return null;
const capture = new URL("/", origin);
capture.searchParams.set("add", source.href);
return capture.href;
}
+14
View File
@@ -0,0 +1,14 @@
{
"manifest_version": 3,
"name": "Jobjakt Quick Capture",
"version": "1.0.0",
"description": "Send the current vacancy to Jobjakt's reviewed import flow.",
"permissions": ["activeTab", "contextMenus"],
"action": {
"default_title": "Save vacancy to Jobjakt"
},
"background": {
"service_worker": "background.mjs",
"type": "module"
}
}
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCaptureUrl } from "../capture-url.mjs";
test("builds the existing reviewed capture route", () => {
const result = new URL(buildCaptureUrl("https://example.test/jobs/42?from=search"));
assert.equal(result.origin, "https://jobs.cesnimda.uk");
assert.equal(result.pathname, "/");
assert.equal(result.searchParams.get("add"), "https://example.test/jobs/42?from=search");
});
test("rejects privileged and malformed URLs", () => {
assert.equal(buildCaptureUrl("chrome://settings"), null);
assert.equal(buildCaptureUrl("javascript:alert(1)"), null);
assert.equal(buildCaptureUrl("not a url"), null);
});
test("supports a local application origin during development", () => {
assert.equal(
buildCaptureUrl("https://arbeidsplassen.nav.no/stillinger/stilling/abc", "http://localhost:3300"),
"http://localhost:3300/?add=https%3A%2F%2Farbeidsplassen.nav.no%2Fstillinger%2Fstilling%2Fabc"
);
});
+2
View File
@@ -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}
+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.
`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 |
+6 -2
View File
@@ -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",
+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());
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 { 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>