Files
jobtrackingapp/JobTrackerApi/Services/JobApplicationHelpers.cs
T
cesnimda deed948183
CI and Deploy / test (pull_request) Successful in 5m4s
CI and Deploy / deploy (pull_request) Has been skipped
feat(jobs): complete workspace draft parity
2026-08-15 17:34:32 +02:00

639 lines
30 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services.JobImport;
namespace JobTrackerApi.Services
{
/// <summary>
/// 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.
/// </summary>
public static class JobApplicationHelpers
{
private const string ApplicationAnswerDraftStart = "<<<APPLICATION_ANSWER_DRAFT>>>";
private const string ApplicationAnswerDraftEnd = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
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<string>();
var contactLines = new List<string>();
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<string>();
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<string> 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<string> 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<TailoredCvCustomSection>()
: new List<TailoredCvCustomSection>
{
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? UpsertSavedApplicationAnswerDraft(string? notes, string? draft)
{
var humanNotes = RemoveSavedApplicationAnswerDraft(notes);
var answer = (draft ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(answer))
{
return string.IsNullOrWhiteSpace(humanNotes) ? null : humanNotes;
}
var answerBlock = $"{ApplicationAnswerDraftStart}\n{answer}\n{ApplicationAnswerDraftEnd}";
return string.IsNullOrWhiteSpace(humanNotes)
? answerBlock
: $"{humanNotes}\n\n{answerBlock}";
}
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<string> BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer)
{
var signals = new List<string>();
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<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
{
var normalized = (status ?? string.Empty).Trim();
var advice = new List<string>();
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<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
var trimmed = s.Trim();
List<string>? jsonTags = null;
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
jsonTags = JsonSerializer.Deserialize<List<string>>(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;
while (true)
{
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
if (startIndex < 0 || endIndex <= startIndex) break;
var before = value[..startIndex].Trim();
var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim();
value = 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<string> BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal)
{
var reminders = new List<string>();
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();
}
}
}