using System.Security.Cryptography; using System.Text; using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Models; using JobTrackerApi.Services.JobImport; namespace JobTrackerApi.Services { /// /// Pure, stateless helpers extracted from JobApplicationsController. None of these touch /// the database, AI services, or other instance state -- same inputs always produce the /// same outputs, so they are safe to share as static methods. /// public static class JobApplicationHelpers { private const string ApplicationAnswerDraftStart = "<<>>"; private const string ApplicationAnswerDraftEnd = "<<>>"; public static string GetPreferredDisplayName(ApplicationUser? user) { if (user is null) return "Your Name"; if (!string.IsNullOrWhiteSpace(user.DisplayName)) return user.DisplayName.Trim(); var fullName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); if (!string.IsNullOrWhiteSpace(fullName)) return fullName; if (!string.IsNullOrWhiteSpace(user.UserName)) return user.UserName.Trim(); if (!string.IsNullOrWhiteSpace(user.Email)) return user.Email.Trim(); return "Your Name"; } public static string BuildGreeting(JobApplication job) { if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) return $"Hi {job.Company.RecruiterName.Trim()},"; if (!string.IsNullOrWhiteSpace(job.Company?.Name)) return $"Hi {job.Company.Name.Trim()} team,"; return "Hi there,"; } public static string BuildStructuredCvContext(ApplicationUser? user) { var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson); var blocks = new List(); var contactLines = new List(); if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}"); if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}"); if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}"); if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}"); if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}"); if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}"); if (structured.Summary.Count > 0) { blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}"); } if (structured.Skills.Count > 0) { blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}"); } if (structured.Jobs.Count > 0) { var jobBlocks = structured.Jobs.Take(3).Select(job => { var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value))); var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}"); return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value))); }).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}"); } if (structured.Education.Count > 0) { var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))); blocks.Add($"Education:\n- {string.Join("\n- ", items)}"); } if (structured.Languages.Count > 0) { var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))); blocks.Add($"Languages:\n- {string.Join("\n- ", items)}"); } if (structured.OtherSections.Count > 0) { var items = structured.OtherSections.Take(2) .Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0) .Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}") .ToList(); if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}"); } if (blocks.Count == 0 && structured.Sections.Count > 0) { blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}")); } return blocks.Count > 0 ? $"Structured CV:\n{string.Join("\n\n", blocks)}" : string.Empty; } public static string BuildCvSearchCorpus(ApplicationUser? user) { var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson); var parts = new List(); if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!); if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!); if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary)); if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills)); if (structured.Jobs.Count > 0) { parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills)))); } if (structured.Education.Count > 0) { parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details)))); } if (structured.Languages.Count > 0) { parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value)))))); } return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part))); } public static string? FormatStructuredDateRange(string? start, string? end, bool isCurrent) { if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null; if (string.IsNullOrWhiteSpace(start)) return end; return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}"; } public static string ComputeGenerationContextHash(string value) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); return Convert.ToHexString(bytes).ToLowerInvariant(); } public static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable matchedTags) { var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) } .Where(value => !string.IsNullOrWhiteSpace(value))) .ToLowerInvariant(); var score = 0; foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag))) { if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4; } score += Math.Min(job.Bullets.Count, 4); return score; } public static List SelectTailoredSkills(StructuredCvProfile structured, string jobText) { var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var prioritized = structured.Skills .Select(skill => new { Skill = skill, Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0 }) .OrderByDescending(entry => entry.Score) .ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) .Select(entry => entry.Skill) .ToList(); if (prioritized.Count == 0) { prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } return prioritized.Take(10).ToList(); } public static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job) { var text = (job.TailoredCvText ?? string.Empty).Trim(); var document = new TailoredCvDocument { Headline = job.JobTitle, CustomSections = string.IsNullOrWhiteSpace(text) ? new List() : new List { new TailoredCvCustomSection { Title = "Legacy draft text", Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(), } } }; return TailoredCvDraftJson.Normalize(document); } public static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft) { var document = TailoredCvDraftJson.FromDraft(draft); return new TailoredCvDraftDto( draft.Id, draft.CanonicalProfileVersion, draft.TemplateId, document.Headline, document.Summary, document.SelectedSkills, document.Experience, document.Education, document.CustomSections, document.RenderOptions, draft.GenerationContextHash, draft.LastGeneratedAtUtc, draft.LastEditedAtUtc, draft.Status, TailoredCvDraftJson.RenderPlainText(document), false); } public static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job) { var document = BuildLegacyTailoredCvFallback(job); return new TailoredCvDraftDto( null, null, "legacy-text", document.Headline, document.Summary, document.SelectedSkills, document.Experience, document.Education, document.CustomSections, document.RenderOptions, null, null, job.TailoredCvUpdatedAt, string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import", TailoredCvDraftJson.RenderPlainText(document), true); } public static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job) { var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job); if (request is null) { return baseDocument; } return TailoredCvDraftJson.Normalize(new TailoredCvDocument { TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal", Headline = request.Headline ?? baseDocument.Headline, Summary = request.Summary ?? baseDocument.Summary, SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills, Experience = request.Experience ?? baseDocument.Experience, Education = request.Education ?? baseDocument.Education, CustomSections = request.CustomSections ?? baseDocument.CustomSections, RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions, }); } public static string? ExtractSavedApplicationAnswerDraft(string? notes) { var value = (notes ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(value)) return null; var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); if (startIndex >= 0 && endIndex > startIndex) { var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim(); return string.IsNullOrWhiteSpace(between) ? null : between; } const string legacyPrefix = "Application answer draft:"; var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); if (legacyIndex >= 0) { var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim(); return string.IsNullOrWhiteSpace(legacy) ? null : legacy; } return null; } public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage) { var subject = (lastMessage?.Subject ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(subject)) { return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? subject : $"Re: {subject}"; } return $"Following up on {job.JobTitle} application"; } public static List BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer) { var signals = new List(); if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}"); if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}"); if (lastMessage is not null) { signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}"); } if (correspondenceContext?.Participants.Count > 0) { signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}"); } if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available"); if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available"); if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available"); if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available"); if (correspondenceContext is not null) { foreach (var signal in correspondenceContext.Signals) { if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal); } } return signals.Take(6).ToList(); } public static bool IsExtractableAttachmentExtension(string? extension) { return extension?.Trim().ToLowerInvariant() switch { ".pdf" => true, ".docx" => true, ".txt" => true, ".md" => true, ".png" => true, ".jpg" => true, ".jpeg" => true, ".webp" => true, _ => false, }; } public static List BuildFollowUpApproach(string status, List matchedTags, List missingTags) { var normalized = (status ?? string.Empty).Trim(); var advice = new List(); switch (normalized) { case "Applied": advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied."); advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background."); break; case "Waiting": advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific."); advice.Add("Use one proof point that shows why you remain a strong fit."); break; case "Interview": case "Interviewing": advice.Add("Focus on momentum, appreciation, and readiness for the next step."); advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible."); break; case "Offer": advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing."); advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress."); break; case "Rejected": advice.Add("If appropriate, ask for feedback with a respectful and concise tone."); advice.Add("Keep the door open for future opportunities instead of arguing the decision."); break; default: advice.Add("Match the tone to the current stage and be specific about why you are following up now."); advice.Add("Keep it concise, credible, and easy to respond to."); break; } if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}."); if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly."); return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList(); } public static IEnumerable SplitTags(string? s) { if (string.IsNullOrWhiteSpace(s)) yield break; var trimmed = s.Trim(); List? jsonTags = null; if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) { try { jsonTags = JsonSerializer.Deserialize>(trimmed); } catch { jsonTags = null; } } if (jsonTags is not null) { foreach (var x in jsonTags) { var t = (x ?? string.Empty).Trim(); if (t.Length == 0) continue; yield return t; } yield break; } foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) { var t = raw.Trim(); if (t.Length == 0) continue; yield return t; } } public static string NormalizeForComparison(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); } public static string BuildSummarySource(JobApplication job) { // Prefer translated text for summaries and skill extraction so non-English // postings become easier to understand while keeping the original text intact. var parts = new[] { job.TranslatedDescription, job.Description, job.Notes }; return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim())); } public static string? NormalizeTags(string? raw) { var normalized = SplitTags(raw) .Select(tag => tag.Trim()) .Where(tag => tag.Length > 0) .GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase) .Select(group => { var first = group.First(); return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant())); }) .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) .ToList(); return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized); } public static string? NormalizeUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return null; var value = url.Trim(); return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value; } public static string RemoveSavedApplicationAnswerDraft(string? notes) { var value = notes ?? string.Empty; if (string.IsNullOrWhiteSpace(value)) return string.Empty; var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); if (startIndex >= 0 && endIndex > startIndex) { var before = value[..startIndex].Trim(); var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim(); return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim(); } const string legacyPrefix = "Application answer draft:"; var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); if (legacyIndex >= 0) { return value[..legacyIndex].Trim(); } return value.Trim(); } public static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes)); public static bool IsInterviewStage(string status) => status.Contains("Interview", StringComparison.OrdinalIgnoreCase); public static bool IsActiveWorkflowStatus(string status) { var normalized = (status ?? string.Empty).Trim(); return normalized switch { "Applied" => true, "Waiting" => true, "Interview" => true, "Interviewing" => true, "Offer" => true, _ => false, }; } public static WorkflowSignalDto BuildWorkflowSignal(JobApplication job, FollowUpDecision followUpDecision) { var hasTailoredCv = !string.IsNullOrWhiteSpace(job.TailoredCvText); var hasSavedApplicationAnswerDraft = !string.IsNullOrWhiteSpace(ExtractSavedApplicationAnswerDraft(job.Notes)); var hasInterviewPrepNotes = HasInterviewPrepNotes(job.Notes); var needsInterviewPrep = IsInterviewStage(job.Status) && !hasInterviewPrepNotes; var hasPackageGap = IsActiveWorkflowStatus(job.Status) && (!hasTailoredCv || !hasSavedApplicationAnswerDraft); var needsFollowUpAction = followUpDecision.NeedsFollowUp || (!job.ResponseReceived && job.FollowUpAt is null); if (needsInterviewPrep) { return new WorkflowSignalDto( ActionKey: "interview-prep", Reason: "Interview stage reached but prep notes are still missing.", WorkspaceTab: "interview-prep", FollowMode: null, NeedsAttention: true, HasPackageGap: hasPackageGap, NeedsInterviewPrep: true, NeedsFollowUpAction: needsFollowUpAction, HasTailoredCv: hasTailoredCv, HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, HasInterviewPrepNotes: hasInterviewPrepNotes); } if (hasPackageGap) { var reason = !hasTailoredCv && !hasSavedApplicationAnswerDraft ? "Tailored CV and saved application answers still need work." : !hasTailoredCv ? "Tailored CV missing for this role." : "Saved application answers still need work."; return new WorkflowSignalDto( ActionKey: "package-work", Reason: reason, WorkspaceTab: "tailored-cv", FollowMode: null, NeedsAttention: true, HasPackageGap: true, NeedsInterviewPrep: needsInterviewPrep, NeedsFollowUpAction: needsFollowUpAction, HasTailoredCv: hasTailoredCv, HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, HasInterviewPrepNotes: hasInterviewPrepNotes); } if (needsFollowUpAction) { var reason = !string.IsNullOrWhiteSpace(followUpDecision.Reason) ? followUpDecision.Reason! : !job.ResponseReceived && job.FollowUpAt is null ? "No response yet and no follow-up is scheduled." : "Follow-up is due for this role."; return new WorkflowSignalDto( ActionKey: "follow-up", Reason: reason, WorkspaceTab: "follow-up", FollowMode: "waiting-update", NeedsAttention: true, HasPackageGap: hasPackageGap, NeedsInterviewPrep: needsInterviewPrep, NeedsFollowUpAction: true, HasTailoredCv: hasTailoredCv, HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, HasInterviewPrepNotes: hasInterviewPrepNotes); } return new WorkflowSignalDto( ActionKey: "review-readiness", Reason: "No urgent workflow gaps are blocking this job right now.", WorkspaceTab: "readiness", FollowMode: null, NeedsAttention: false, HasPackageGap: hasPackageGap, NeedsInterviewPrep: needsInterviewPrep, NeedsFollowUpAction: needsFollowUpAction, HasTailoredCv: hasTailoredCv, HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, HasInterviewPrepNotes: hasInterviewPrepNotes); } public static List BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal) { var reminders = new List(); if (workflowSignal.HasPackageGap) { reminders.Add(workflowSignal.HasTailoredCv ? "Saved application answers are still missing from the package." : workflowSignal.HasSavedApplicationAnswerDraft ? "This role is active but still missing a tailored CV." : "This role is active but still needs a tailored CV and saved application answers."); } if (workflowSignal.NeedsInterviewPrep) { reminders.Add("Interview stage reached but prep notes are still missing."); } if (workflowSignal.NeedsFollowUpAction) { reminders.Add(job.FollowUpAt is null ? "No response yet and no follow-up is scheduled." : workflowSignal.Reason); } return reminders .Where(reminder => !string.IsNullOrWhiteSpace(reminder)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } } }