using JobTrackerApi.Data; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; // Applies deterministic auto-ghost transitions only when explicitly enabled. public sealed class RulesHostedService( BackgroundTenantRunner tenants, IConfiguration configuration, ILogger logger, IStartupReadiness startupReadiness) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await startupReadiness.WaitUntilReadyAsync(stoppingToken); if (!configuration.GetValue("Workers:RulesEnabled", false)) { logger.LogInformation("Rules worker disabled (Workers:RulesEnabled=false)."); return; } await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); while (!stoppingToken.IsCancellationRequested) { await RunOnceAsync(stoppingToken); await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken); } } public Task RunOnceAsync(CancellationToken cancellationToken) { if (!configuration.GetValue("Workers:RulesEnabled", false)) return Task.FromResult(BackgroundWorkerRunResult.Disabled); return tenants.RunForJobOwnersAsync("rules", ProcessOwnerAsync, cancellationToken); } private static async Task ProcessOwnerAsync(IServiceProvider services, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var settings = await RulesEngine.GetSettings(db, cancellationToken); var now = DateTime.Now; var lastMessages = await db.Correspondences .GroupBy(message => message.JobApplicationId) .Select(group => new { JobApplicationId = group.Key, Last = group.Max(x => x.Date) }) .ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken); var jobs = await db.JobApplications .Where(job => !job.IsDeleted && job.Status != "Ghosted") .ToListAsync(cancellationToken); foreach (var job in jobs) { lastMessages.TryGetValue(job.Id, out var lastMessage); if (RulesEngine.Evaluate(settings, job, now, lastMessage).ShouldGhost) job.Status = "Ghosted"; } await db.SaveChangesAsync(cancellationToken); } }