Add reminder emails and AI CV improvement tools

This commit is contained in:
cesnimda
2026-03-23 21:46:37 +01:00
parent 66d924e880
commit 5a31f86e4d
9 changed files with 200 additions and 5 deletions
@@ -91,6 +91,34 @@ public sealed class ProfileCvController : ControllerBase
return Ok(new { imported = true, characters = text.Length });
}
[HttpPost("improve")]
public async Task<IActionResult> Improve()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before improving it.");
var improved = await _aiService.SummarizeSectionAsync(
"Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.",
user.ProfileCvText,
1800,
500);
if (string.IsNullOrWhiteSpace(improved))
{
return BadRequest("The AI service could not improve your CV text right now.");
}
user.ProfileCvText = improved.Trim();
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText });
}
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
{
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
+3
View File
@@ -106,6 +106,7 @@ builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
.SetApplicationName("JobTracker");
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
@@ -666,6 +667,7 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
@@ -727,6 +729,7 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
@@ -0,0 +1,127 @@
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<FollowUpReminderHostedService> _logger;
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger)
{
_services = services;
_cfg = cfg;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken 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: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>();
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 detailsUrl = $"{baseUrl}/jobs?open={job.Id}&tab=4";
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"
};
}
}