using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Models; namespace JobTrackerApi.Services { public sealed record FollowUpDecision(bool NeedsFollowUp, string? Reason, bool ShouldGhost); public static class RulesEngine { public static async Task GetSettings(JobTrackerContext db, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(db.CurrentUserId)) { var u = await db.UserRuleSettings .AsNoTracking() .FirstOrDefaultAsync(x => x.OwnerUserId == db.CurrentUserId, cancellationToken); if (u is not null) { return new RuleSettings { Id = 1, AppliedFollowUpDays = u.AppliedFollowUpDays, AppliedGhostDays = u.AppliedGhostDays, OfferFollowUpDays = u.OfferFollowUpDays, OfferGhostDays = u.OfferGhostDays, FeedbackFollowUpDays = u.FeedbackFollowUpDays, FeedbackGhostDays = u.FeedbackGhostDays, }; } } var s = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 1, cancellationToken); return s ?? new RuleSettings { Id = 1 }; } public static FollowUpDecision Evaluate( RuleSettings s, JobApplication job, DateTime now, DateTime? lastMessageAt ) { if (job.IsDeleted) return new FollowUpDecision(false, null, false); var status = job.Status ?? "Applied"; if (status == "Interviewing") status = "Interview"; // Last activity: any explicit follow-up date, response date, feedback request, or correspondence message. var last = Max( job.DateApplied, job.ResponseDate, job.FollowUpAt, job.FeedbackRequestedAt, lastMessageAt ); var daysSinceLast = (now - last).TotalDays; // Applied: if no response and enough time passed since applied. if (string.Equals(status, "Applied", StringComparison.OrdinalIgnoreCase) && !job.ResponseReceived) { var daysSinceApplied = (now - job.DateApplied).TotalDays; if (daysSinceApplied >= s.AppliedFollowUpDays) return new FollowUpDecision(true, $"No reply after {s.AppliedFollowUpDays}d", daysSinceApplied >= s.AppliedGhostDays); return new FollowUpDecision(false, null, daysSinceApplied >= s.AppliedGhostDays); } // Offer/accepted waiting on next step if (string.Equals(status, "Offer", StringComparison.OrdinalIgnoreCase)) { if (daysSinceLast >= s.OfferFollowUpDays) return new FollowUpDecision(true, $"Stalled after {s.OfferFollowUpDays}d", daysSinceLast >= s.OfferGhostDays); return new FollowUpDecision(false, null, daysSinceLast >= s.OfferGhostDays); } // Rejected but feedback requested if (string.Equals(status, "Rejected", StringComparison.OrdinalIgnoreCase) && job.FeedbackRequestedAt is not null) { var daysSinceReq = (now - job.FeedbackRequestedAt.Value).TotalDays; if (daysSinceReq >= s.FeedbackFollowUpDays) return new FollowUpDecision(true, $"Feedback requested {s.FeedbackFollowUpDays}d ago", daysSinceReq >= s.FeedbackGhostDays); return new FollowUpDecision(false, null, daysSinceReq >= s.FeedbackGhostDays); } // Waiting status: treat as follow-up based on applied follow-up days. if (string.Equals(status, "Waiting", StringComparison.OrdinalIgnoreCase)) { if (daysSinceLast >= s.AppliedFollowUpDays) return new FollowUpDecision(true, $"Waiting {s.AppliedFollowUpDays}d", daysSinceLast >= s.AppliedGhostDays); return new FollowUpDecision(false, null, daysSinceLast >= s.AppliedGhostDays); } // Default: no follow-up rule. Do not auto-ghost other statuses. return new FollowUpDecision(false, null, false); } public static DateTime Max(DateTime a, params DateTime?[] rest) { var m = a; foreach (var r in rest) { if (r is not null && r.Value > m) m = r.Value; } return m; } } }