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); } }