using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed class FollowUpReminderHostedService( BackgroundTenantRunner tenants, IConfiguration configuration, ILogger logger, IStartupReadiness startupReadiness, ExternalOrigin externalOrigin) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await startupReadiness.WaitUntilReadyAsync(stoppingToken); if (!IsEnabled()) { logger.LogInformation("Follow-up reminder worker disabled; both Workers:FollowUpRemindersEnabled and Email:FollowUpReminders:Enabled must be true."); return; } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); while (!stoppingToken.IsCancellationRequested) { await RunOnceAsync(stoppingToken); await Task.Delay(TimeSpan.FromHours(6), stoppingToken); } } public Task RunOnceAsync(CancellationToken cancellationToken) { if (!IsEnabled()) return Task.FromResult(BackgroundWorkerRunResult.Disabled); return tenants.RunForJobOwnersAsync("follow-up-reminders", ProcessOwnerAsync, cancellationToken); } private bool IsEnabled() => configuration.GetValue("Workers:FollowUpRemindersEnabled", false) && configuration.GetValue("Email:FollowUpReminders:Enabled", false); private async Task ProcessOwnerAsync(IServiceProvider services, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var users = services.GetRequiredService>(); var email = services.GetRequiredService(); var settings = await RulesEngine.GetSettings(db, cancellationToken); var now = DateTime.Now; var lookAheadDays = Math.Clamp(configuration.GetValue("Email:FollowUpReminders:UpcomingDays", 2), 1, 14); var upcomingTo = now.AddDays(lookAheadDays); var lastMessages = await db.Correspondences.AsNoTracking() .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.Include(job => job.Company) .Where(job => !job.IsDeleted && job.OwnerUserId != null) .Where(job => (job.FollowUpAt != null && job.FollowUpAt <= upcomingTo) || job.Status == "Applied" || job.Status == "Waiting" || job.Status == "Offer" || (job.Status == "Rejected" && job.FeedbackRequestedAt != null)) .ToListAsync(cancellationToken); foreach (var job in jobs) { if (job.OwnerUserId is null || job.LastReminderEmailSentAt?.Date == now.Date) continue; lastMessages.TryGetValue(job.Id, out var lastMessage); var decision = RulesEngine.Evaluate(settings, job, now, lastMessage); var upcoming = job.FollowUpAt is not null && job.FollowUpAt.Value <= upcomingTo; if (!decision.NeedsFollowUp && !upcoming) continue; var owner = await users.FindByIdAsync(job.OwnerUserId); if (owner is null || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue; var followMode = SuggestFollowUpMode(job.Status); var detailsUrl = externalOrigin.BuildPath($"/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}"); var companyName = job.Company?.Name ?? "Unknown company"; var appliedOn = job.DateApplied?.ToString("MMMM d, yyyy") ?? "an unrecorded date"; var subject = $"Follow up reminder: {job.JobTitle} at {companyName}"; var body = string.Join("\n\n", new[] { $"Hi {(owner.UserName ?? owner.Email ?? "there")},", $"This is your Jobbjakt reminder to follow up on the {job.JobTitle} role at {companyName}.", $"Applied on: {appliedOn}\nCurrent status: {job.Status}\nWhy now: {BuildReminderReason(job, decision.Reason, upcoming)}", $"Open the follow-up generator for this job:\n{detailsUrl}", "Tip: review the generated follow-up draft, candidate-fit notes, and recruiter message before sending.", "— Jobbjakt" }); await email.SendAsync(owner.Email!, subject, body, cancellationToken); job.LastReminderEmailSentAt = now; } await db.SaveChangesAsync(cancellationToken); } private static string BuildReminderReason(JobApplication job, string? engineReason, bool upcoming) { if (upcoming && job.FollowUpAt is not null) return $"a follow-up date is scheduled for {job.FollowUpAt.Value:MMMM d, yyyy}"; if (!string.IsNullOrWhiteSpace(engineReason)) return engineReason.Trim(); return job.Status switch { "Applied" => "you applied and have not logged a response yet", "Waiting" => "you are waiting on next steps", "Offer" => "the process appears to be stalled after progress", _ => "the application may need attention" }; } private static string SuggestFollowUpMode(string? status) => (status ?? string.Empty).Trim() switch { "Waiting" => "waiting-update", "Interview" or "Interviewing" => "post-interview", "Offer" => "offer-checkin", "Rejected" => "feedback-request", _ => "post-apply", }; }