feat(discovery): schedule saved-search alerts
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user