feat(discovery): schedule saved-search alerts

This commit is contained in:
cesnimda
2026-08-31 22:12:52 +02:00
parent 19a89018a0
commit b7029faaec
16 changed files with 423 additions and 113 deletions
@@ -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);
}
}