From 5a31f86e4d5451a3b4de4289ff3df65939f80552 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 23 Mar 2026 21:46:37 +0100 Subject: [PATCH] Add reminder emails and AI CV improvement tools --- .../Controllers/ProfileCvController.cs | 28 ++++ JobTrackerApi/Program.cs | 3 + .../Services/FollowUpReminderHostedService.cs | 127 ++++++++++++++++++ Models/JobApplication.cs | 1 + docs/jobbjakt-cleanup-tracker.md | 3 + .../src/components/JobDetailsDialog.tsx | 7 +- job-tracker-ui/src/components/JobTable.tsx | 6 +- job-tracker-ui/src/i18n/translations.ts | 8 ++ job-tracker-ui/src/pages/ProfilePage.tsx | 22 ++- 9 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 JobTrackerApi/Services/FollowUpReminderHostedService.cs diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 5acbd5e..18c2f9e 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -91,6 +91,34 @@ public sealed class ProfileCvController : ControllerBase return Ok(new { imported = true, characters = text.Length }); } + [HttpPost("improve")] + public async Task 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 ExtractTextAsync(IFormFile file, string extension) { if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index b046fe5..a827731 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -106,6 +106,7 @@ builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)) .SetApplicationName("JobTracker"); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -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")) { diff --git a/JobTrackerApi/Services/FollowUpReminderHostedService.cs b/JobTrackerApi/Services/FollowUpReminderHostedService.cs new file mode 100644 index 0000000..8694791 --- /dev/null +++ b/JobTrackerApi/Services/FollowUpReminderHostedService.cs @@ -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 _logger; + + public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger 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(); + 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 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" + }; + } +} diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index 42021cf..86de0e1 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -44,6 +44,7 @@ public class JobApplication public string? ShortSummary { get; set; } public string? TailoredCvText { get; set; } public DateTime? TailoredCvUpdatedAt { get; set; } + public DateTime? LastReminderEmailSentAt { get; set; } public List Messages { get; set; } = new(); public List Attachments { get; set; } = new(); diff --git a/docs/jobbjakt-cleanup-tracker.md b/docs/jobbjakt-cleanup-tracker.md index 43b523e..a32fabc 100644 --- a/docs/jobbjakt-cleanup-tracker.md +++ b/docs/jobbjakt-cleanup-tracker.md @@ -9,6 +9,7 @@ Last updated: 2026-03-23 - [x] Add OCR support for supported image CV uploads (`png`, `jpg`, `jpeg`, `webp`) - [x] Add AI service latency/OCR telemetry to the system page - [x] Add frontend test coverage for AI service status rendering +- [x] Add CV text improvement flow powered by the AI service - [ ] Extend AI extraction to job attachment ingestion - [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*` @@ -49,6 +50,7 @@ Last updated: 2026-03-23 - [x] Move SMTP test from user page to admin/system page - [x] Move language selector into Settings - [x] Make language selection apply globally +- [x] Add scheduled follow-up reminder emails with direct links to the generator - [ ] Audit app-wide preference for username over email - [ ] Verify auto-fill of email and full name from profile where relevant @@ -77,5 +79,6 @@ Last updated: 2026-03-23 ## UX / Consistency - [x] Simplify create-job workflow - [x] Reduce duplicated UI/data across multiple pages +- [x] Add direct follow-up deep links for specific jobs - [ ] Perform final UX clarity pass across major screens - [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 13b923d..90ccfed 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -41,6 +41,7 @@ interface Props { open: boolean; jobId: number | null; onClose: () => void; + initialTab?: number; } function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" { @@ -69,7 +70,7 @@ function copyLines(items: string[]) { return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n")); } -export default function JobDetailsDialog({ open, jobId, onClose }: Props) { +export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 }: Props) { const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); @@ -100,7 +101,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { useEffect(() => { if (!open || !jobId) return; - setTab(0); + setTab(Math.max(0, Math.min(8, initialTab))); setFollowUpDraft(null); setCandidateFit(null); setInterviewPrep(null); @@ -113,7 +114,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { }); api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false)); api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); - }, [open, jobId]); + }, [open, jobId, initialTab]); useEffect(() => { if (!open || !jobId || tab !== 4 || followUpDraft) return; diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 9b6e078..c6a7aa9 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -147,6 +147,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const { companies } = useCompanies(); const [companyFilterId, setCompanyFilterId] = useState("All"); const [detailsJobId, setDetailsJobId] = useState(null); + const [detailsInitialTab, setDetailsInitialTab] = useState(0); const [editJobId, setEditJobId] = useState(null); const [reloadToken, setReloadToken] = useState(0); const [statusAnchor, setStatusAnchor] = useState(null); @@ -179,11 +180,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col useEffect(() => { const paramsSearch = new URLSearchParams(location.search); const openId = Number(paramsSearch.get("open") || 0); + const tabIndex = Number(paramsSearch.get("tab") || 0); if (!openId || jobs.length === 0) return; const job = jobs.find((j) => j.id === openId); if (!job) return; setDetailsJobId(openId); + setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(8, tabIndex)) : 0); paramsSearch.delete("open"); + paramsSearch.delete("tab"); navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true }); }, [jobs, location.pathname, location.search, navigate]); @@ -409,7 +413,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} /> - setDetailsJobId(null)} /> + { setDetailsJobId(null); setDetailsInitialTab(0); }} /> setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> {(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })})} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index feed7f9..15294e5 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -175,6 +175,10 @@ export const translations = { profileMasterCv: "Master CV", profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.", profileUploadCv: "Upload CV", + profileCvImprove: "Improve CV text", + profileCvImproving: "Improving CV...", + profileCvImproved: "CV text improved.", + profileCvImproveFailed: "Failed to improve CV text.", profileUploading: "Uploading...", profileCopyCvText: "Copy CV text", profileCvUploaded: "CV uploaded and processed.", @@ -896,6 +900,10 @@ export const translations = { profileMasterCv: "Hoved-CV", profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.", profileUploadCv: "Last opp CV", + profileCvImprove: "Forbedre CV-tekst", + profileCvImproving: "Forbedrer CV...", + profileCvImproved: "CV-tekst forbedret.", + profileCvImproveFailed: "Kunne ikke forbedre CV-tekst.", profileUploading: "Laster opp...", profileCopyCvText: "Kopier CV-tekst", profileCvUploaded: "CV lastet opp og behandlet.", diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 9a0e1a8..8d8a354 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -51,6 +51,7 @@ export default function ProfilePage() { const [me, setMe] = useState(null); const [loading, setLoading] = useState(false); const [uploadingCv, setUploadingCv] = useState(false); + const [improvingCv, setImprovingCv] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [avatarFile, setAvatarFile] = useState(null); const [cropOpen, setCropOpen] = useState(false); @@ -247,9 +248,28 @@ export default function ProfilePage() { } }} /> - +