using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed class FollowUpReminderHostedService : BackgroundService { private readonly IServiceProvider _services; private readonly IConfiguration _cfg; private readonly ILogger _logger; private readonly IStartupReadiness _startupReadiness; public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger logger, IStartupReadiness startupReadiness) { _services = services; _cfg = cfg; _logger = logger; _startupReadiness = startupReadiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await _startupReadiness.WaitUntilReadyAsync(stoppingToken); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); while (!stoppingToken.IsCancellationRequested) { try { await SendDueReminderEmailsAsync(stoppingToken); } catch (Exception ex) { _logger.LogWarning(ex, "Follow-up reminder email pass failed."); } await Task.Delay(TimeSpan.FromHours(6), stoppingToken); } } private async Task SendDueReminderEmailsAsync(CancellationToken cancellationToken) { var enabled = _cfg.GetValue("Email:FollowUpReminders:Enabled", false); if (!enabled) return; var baseUrl = (_cfg["App:PublicBaseUrl"] ?? _cfg["App:BaseUrl"] ?? _cfg["Frontend:BaseUrl"] ?? string.Empty).Trim().TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) return; using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var users = scope.ServiceProvider.GetRequiredService>(); var email = scope.ServiceProvider.GetRequiredService(); var settings = await RulesEngine.GetSettings(db, cancellationToken); var now = DateTime.Now; var lookAheadDays = Math.Clamp(_cfg.GetValue("Email:FollowUpReminders:UpcomingDays", 2), 1, 14); var upcomingTo = now.AddDays(lookAheadDays); var lastMsg = await db.Correspondences .AsNoTracking() .GroupBy(c => c.JobApplicationId) .Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) }) .ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken); var jobs = await db.JobApplications .Include(j => j.Company) .Where(j => !j.IsDeleted && j.OwnerUserId != null) .Where(j => (j.FollowUpAt != null && j.FollowUpAt <= upcomingTo) || j.Status == "Applied" || j.Status == "Waiting" || j.Status == "Offer" || (j.Status == "Rejected" && j.FeedbackRequestedAt != null)) .ToListAsync(cancellationToken); foreach (var job in jobs) { if (job.OwnerUserId is null) continue; if (job.LastReminderEmailSentAt?.Date == now.Date) continue; lastMsg.TryGetValue(job.Id, out var lm); var decision = RulesEngine.Evaluate(settings, job, now, lm); 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 reason = BuildReminderReason(job, decision.Reason, upcoming); var followMode = SuggestFollowUpMode(job.Status); var detailsUrl = $"{baseUrl}/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"); 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: {reason}", $"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) { return (status ?? string.Empty).Trim() switch { "Waiting" => "waiting-update", "Interview" => "post-interview", "Interviewing" => "post-interview", "Offer" => "offer-checkin", "Rejected" => "feedback-request", _ => "post-apply", }; } }