feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -5,102 +5,84 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class FollowUpReminderHostedService : BackgroundService
|
||||
public sealed class FollowUpReminderHostedService(
|
||||
BackgroundTenantRunner tenants,
|
||||
IConfiguration configuration,
|
||||
ILogger<FollowUpReminderHostedService> logger,
|
||||
IStartupReadiness startupReadiness,
|
||||
ExternalOrigin externalOrigin) : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<FollowUpReminderHostedService> _logger;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> 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);
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendDueReminderEmailsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Follow-up reminder email pass failed.");
|
||||
}
|
||||
|
||||
await RunOnceAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendDueReminderEmailsAsync(CancellationToken cancellationToken)
|
||||
public Task<BackgroundWorkerRunResult> RunOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var enabled = _cfg.GetValue("Email:FollowUpReminders:Enabled", false);
|
||||
if (!enabled) return;
|
||||
if (!IsEnabled()) return Task.FromResult(BackgroundWorkerRunResult.Disabled);
|
||||
return tenants.RunForJobOwnersAsync("follow-up-reminders", ProcessOwnerAsync, cancellationToken);
|
||||
}
|
||||
|
||||
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<JobTrackerContext>();
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var email = scope.ServiceProvider.GetRequiredService<IAppEmailSender>();
|
||||
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<JobTrackerContext>();
|
||||
var users = services.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var email = services.GetRequiredService<IAppEmailSender>();
|
||||
var settings = await RulesEngine.GetSettings(db, cancellationToken);
|
||||
var now = DateTime.Now;
|
||||
var lookAheadDays = Math.Clamp(_cfg.GetValue("Email:FollowUpReminders:UpcomingDays", 2), 1, 14);
|
||||
var lookAheadDays = Math.Clamp(configuration.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) })
|
||||
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(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))
|
||||
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) continue;
|
||||
if (job.LastReminderEmailSentAt?.Date == now.Date) continue;
|
||||
|
||||
lastMsg.TryGetValue(job.Id, out var lm);
|
||||
var decision = RulesEngine.Evaluate(settings, job, now, lm);
|
||||
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 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 detailsUrl = externalOrigin.BuildPath($"/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}");
|
||||
var companyName = job.Company?.Name ?? "Unknown company";
|
||||
// RulesEngine never raises a follow-up for a job with no DateApplied, so this should
|
||||
// always have a value; the fallback just keeps the email readable rather than throwing.
|
||||
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: {reason}",
|
||||
$"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"
|
||||
@@ -116,12 +98,8 @@ public sealed class FollowUpReminderHostedService : BackgroundService
|
||||
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",
|
||||
@@ -131,16 +109,12 @@ public sealed class FollowUpReminderHostedService : BackgroundService
|
||||
};
|
||||
}
|
||||
|
||||
private static string SuggestFollowUpMode(string? status)
|
||||
private static string SuggestFollowUpMode(string? status) => (status ?? string.Empty).Trim() switch
|
||||
{
|
||||
return (status ?? string.Empty).Trim() switch
|
||||
{
|
||||
"Waiting" => "waiting-update",
|
||||
"Interview" => "post-interview",
|
||||
"Interviewing" => "post-interview",
|
||||
"Offer" => "offer-checkin",
|
||||
"Rejected" => "feedback-request",
|
||||
_ => "post-apply",
|
||||
};
|
||||
}
|
||||
"Waiting" => "waiting-update",
|
||||
"Interview" or "Interviewing" => "post-interview",
|
||||
"Offer" => "offer-checkin",
|
||||
"Rejected" => "feedback-request",
|
||||
_ => "post-apply",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user