using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; namespace JobTrackerApi.Services { // Periodically applies "auto ghost" transitions. public sealed class RulesHostedService : BackgroundService { private readonly IServiceProvider _services; public RulesHostedService(IServiceProvider services) { _services = services; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Small initial delay to let app start. await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); while (!stoppingToken.IsCancellationRequested) { try { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var settings = await RulesEngine.GetSettings(db, stoppingToken); var now = DateTime.Now; // Get last correspondence per job (single query). var lastMsg = await db.Correspondences .GroupBy(c => c.JobApplicationId) .Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) }) .ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, stoppingToken); var jobs = await db.JobApplications .Where(j => !j.IsDeleted && j.Status != "Ghosted") .ToListAsync(stoppingToken); var changed = 0; foreach (var j in jobs) { lastMsg.TryGetValue(j.Id, out var lm); var d = RulesEngine.Evaluate(settings, j, now, lm); if (d.ShouldGhost) { j.Status = "Ghosted"; changed++; } } if (changed > 0) await db.SaveChangesAsync(stoppingToken); } catch { // Best-effort background job; swallow errors. } await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken); } } } }