feat(S05/T01): Unified workflow trust signals across the API, table, da…
- JobTrackerApi/Controllers/JobApplicationsController.cs - JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs - job-tracker-ui/src/jobWorkflowSignals.ts - job-tracker-ui/src/components/JobTable.tsx - job-tracker-ui/src/components/DashboardView.tsx - job-tracker-ui/src/components/RemindersView.tsx - job-tracker-ui/src/workflow-trust-signals.test.tsx
This commit is contained in:
@@ -388,6 +388,47 @@ namespace JobTrackerApi.Controllers
|
||||
return variants;
|
||||
}
|
||||
|
||||
private JobApplicationDto BuildJobApplicationDto(JobApplication job, FollowUpDecision followUpDecision, string? followUpReasonOverride = null, string? fullSummary = null)
|
||||
{
|
||||
var workflowSignal = BuildWorkflowSignal(job, followUpDecision);
|
||||
|
||||
return new JobApplicationDto(
|
||||
Id: job.Id,
|
||||
CompanyId: job.CompanyId,
|
||||
Company: job.Company,
|
||||
JobTitle: job.JobTitle,
|
||||
Status: job.Status,
|
||||
DateApplied: job.DateApplied,
|
||||
ResponseReceived: job.ResponseReceived,
|
||||
ResponseDate: job.ResponseDate,
|
||||
Notes: job.Notes,
|
||||
CoverLetterText: job.CoverLetterText,
|
||||
JobUrl: job.JobUrl,
|
||||
Description: job.Description,
|
||||
TranslatedDescription: job.TranslatedDescription,
|
||||
DescriptionLanguage: job.DescriptionLanguage,
|
||||
Tags: job.Tags,
|
||||
Deadline: job.Deadline,
|
||||
Location: job.Location,
|
||||
Salary: job.Salary,
|
||||
NextAction: job.NextAction,
|
||||
FollowUpAt: job.FollowUpAt,
|
||||
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
||||
HasResume: job.HasResume,
|
||||
HasCoverLetter: job.HasCoverLetter,
|
||||
HasPortfolio: job.HasPortfolio,
|
||||
HasOtherAttachment: job.HasOtherAttachment,
|
||||
IsDeleted: job.IsDeleted,
|
||||
DeletedAt: job.DeletedAt,
|
||||
DaysSince: job.DaysSince,
|
||||
NeedsFollowUp: followUpDecision.NeedsFollowUp,
|
||||
FollowUpReason: followUpReasonOverride ?? followUpDecision.Reason,
|
||||
TailoredCvText: job.TailoredCvText,
|
||||
WorkflowSignal: workflowSignal,
|
||||
ShortSummary: job.ShortSummary,
|
||||
FullSummary: fullSummary);
|
||||
}
|
||||
|
||||
private static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
||||
{
|
||||
var normalized = (status ?? string.Empty).Trim();
|
||||
@@ -510,6 +551,176 @@ namespace JobTrackerApi.Controllers
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
|
||||
}
|
||||
|
||||
private 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();
|
||||
}
|
||||
|
||||
private static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes));
|
||||
|
||||
private static bool IsInterviewStage(string status) =>
|
||||
status.Contains("Interview", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private 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 sealed record WorkflowSignalDto(
|
||||
string ActionKey,
|
||||
string Reason,
|
||||
string WorkspaceTab,
|
||||
string? FollowMode,
|
||||
bool NeedsAttention,
|
||||
bool HasPackageGap,
|
||||
bool NeedsInterviewPrep,
|
||||
bool NeedsFollowUpAction,
|
||||
bool HasTailoredCv,
|
||||
bool HasSavedApplicationAnswerDraft,
|
||||
bool HasInterviewPrepNotes
|
||||
);
|
||||
|
||||
private 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);
|
||||
}
|
||||
|
||||
private 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();
|
||||
}
|
||||
|
||||
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
|
||||
|
||||
@@ -544,6 +755,8 @@ namespace JobTrackerApi.Controllers
|
||||
int DaysSince,
|
||||
bool NeedsFollowUp,
|
||||
string? FollowUpReason,
|
||||
string? TailoredCvText,
|
||||
WorkflowSignalDto WorkflowSignal,
|
||||
string? ShortSummary,
|
||||
string? FullSummary
|
||||
);
|
||||
@@ -658,40 +871,7 @@ namespace JobTrackerApi.Controllers
|
||||
// Use persisted short summary when available to avoid repeated model calls.
|
||||
var shortSummary = j.ShortSummary;
|
||||
var summary = shortSummary; // list endpoints return the short summary only
|
||||
dtoItems.Add(new JobApplicationDto(
|
||||
Id: j.Id,
|
||||
CompanyId: j.CompanyId,
|
||||
Company: j.Company,
|
||||
JobTitle: j.JobTitle,
|
||||
Status: j.Status,
|
||||
DateApplied: j.DateApplied,
|
||||
ResponseReceived: j.ResponseReceived,
|
||||
ResponseDate: j.ResponseDate,
|
||||
Notes: j.Notes,
|
||||
CoverLetterText: j.CoverLetterText,
|
||||
JobUrl: j.JobUrl,
|
||||
Description: j.Description,
|
||||
TranslatedDescription: j.TranslatedDescription,
|
||||
DescriptionLanguage: j.DescriptionLanguage,
|
||||
Tags: j.Tags,
|
||||
Deadline: j.Deadline,
|
||||
Location: j.Location,
|
||||
Salary: j.Salary,
|
||||
NextAction: j.NextAction,
|
||||
FollowUpAt: j.FollowUpAt,
|
||||
FeedbackRequestedAt: j.FeedbackRequestedAt,
|
||||
HasResume: j.HasResume,
|
||||
HasCoverLetter: j.HasCoverLetter,
|
||||
HasPortfolio: j.HasPortfolio,
|
||||
HasOtherAttachment: j.HasOtherAttachment,
|
||||
IsDeleted: j.IsDeleted,
|
||||
DeletedAt: j.DeletedAt,
|
||||
DaysSince: j.DaysSince,
|
||||
NeedsFollowUp: d.NeedsFollowUp,
|
||||
FollowUpReason: d.Reason,
|
||||
ShortSummary: shortSummary,
|
||||
FullSummary: null
|
||||
));
|
||||
dtoItems.Add(BuildJobApplicationDto(j, d));
|
||||
}
|
||||
|
||||
return Ok(new PagedResult<JobApplicationDto>(dtoItems, totalCount, page, pageSize));
|
||||
@@ -722,40 +902,7 @@ namespace JobTrackerApi.Controllers
|
||||
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
||||
var shortSummary = j.ShortSummary;
|
||||
var summary = shortSummary;
|
||||
dtos.Add(new JobApplicationDto(
|
||||
Id: j.Id,
|
||||
CompanyId: j.CompanyId,
|
||||
Company: j.Company,
|
||||
JobTitle: j.JobTitle,
|
||||
Status: j.Status,
|
||||
DateApplied: j.DateApplied,
|
||||
ResponseReceived: j.ResponseReceived,
|
||||
ResponseDate: j.ResponseDate,
|
||||
Notes: j.Notes,
|
||||
CoverLetterText: j.CoverLetterText,
|
||||
JobUrl: j.JobUrl,
|
||||
Description: j.Description,
|
||||
TranslatedDescription: j.TranslatedDescription,
|
||||
DescriptionLanguage: j.DescriptionLanguage,
|
||||
Tags: j.Tags,
|
||||
Deadline: j.Deadline,
|
||||
Location: j.Location,
|
||||
Salary: j.Salary,
|
||||
NextAction: j.NextAction,
|
||||
FollowUpAt: j.FollowUpAt,
|
||||
FeedbackRequestedAt: j.FeedbackRequestedAt,
|
||||
HasResume: j.HasResume,
|
||||
HasCoverLetter: j.HasCoverLetter,
|
||||
HasPortfolio: j.HasPortfolio,
|
||||
HasOtherAttachment: j.HasOtherAttachment,
|
||||
IsDeleted: j.IsDeleted,
|
||||
DeletedAt: j.DeletedAt,
|
||||
DaysSince: j.DaysSince,
|
||||
NeedsFollowUp: d.NeedsFollowUp,
|
||||
FollowUpReason: d.Reason,
|
||||
ShortSummary: shortSummary,
|
||||
FullSummary: null
|
||||
));
|
||||
dtos.Add(BuildJobApplicationDto(j, d));
|
||||
}
|
||||
|
||||
return Ok(new PagedResult<JobApplicationDto>(dtos, total, page, pageSize));
|
||||
@@ -782,40 +929,7 @@ namespace JobTrackerApi.Controllers
|
||||
// surface readable English analysis while the original text remains available.
|
||||
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
|
||||
|
||||
return Ok(new JobApplicationDto(
|
||||
Id: job.Id,
|
||||
CompanyId: job.CompanyId,
|
||||
Company: job.Company,
|
||||
JobTitle: job.JobTitle,
|
||||
Status: job.Status,
|
||||
DateApplied: job.DateApplied,
|
||||
ResponseReceived: job.ResponseReceived,
|
||||
ResponseDate: job.ResponseDate,
|
||||
Notes: job.Notes,
|
||||
CoverLetterText: job.CoverLetterText,
|
||||
JobUrl: job.JobUrl,
|
||||
Description: job.Description,
|
||||
TranslatedDescription: job.TranslatedDescription,
|
||||
DescriptionLanguage: job.DescriptionLanguage,
|
||||
Tags: job.Tags,
|
||||
Deadline: job.Deadline,
|
||||
Location: job.Location,
|
||||
Salary: job.Salary,
|
||||
NextAction: job.NextAction,
|
||||
FollowUpAt: job.FollowUpAt,
|
||||
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
||||
HasResume: job.HasResume,
|
||||
HasCoverLetter: job.HasCoverLetter,
|
||||
HasPortfolio: job.HasPortfolio,
|
||||
HasOtherAttachment: job.HasOtherAttachment,
|
||||
IsDeleted: job.IsDeleted,
|
||||
DeletedAt: job.DeletedAt,
|
||||
DaysSince: job.DaysSince,
|
||||
NeedsFollowUp: d.NeedsFollowUp,
|
||||
FollowUpReason: d.Reason,
|
||||
ShortSummary: job.ShortSummary,
|
||||
FullSummary: full
|
||||
));
|
||||
return Ok(BuildJobApplicationDto(job, d, fullSummary: full));
|
||||
}
|
||||
|
||||
[HttpGet("board")]
|
||||
@@ -875,62 +989,10 @@ namespace JobTrackerApi.Controllers
|
||||
lastMsg.TryGetValue(j.Id, out var lm);
|
||||
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
||||
var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo;
|
||||
if (!d.NeedsFollowUp && !upcoming) continue;
|
||||
var shortSummary = j.ShortSummary;
|
||||
var reminderReason = d.Reason;
|
||||
if (string.IsNullOrWhiteSpace(j.TailoredCvText))
|
||||
{
|
||||
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
|
||||
? "Tailored CV missing for an active role."
|
||||
: $"{reminderReason} Tailored CV missing.";
|
||||
}
|
||||
if (j.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(j.Notes))
|
||||
{
|
||||
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
|
||||
? "Interview coming up but prep notes are missing."
|
||||
: $"{reminderReason} Interview prep notes missing.";
|
||||
}
|
||||
if (!j.ResponseReceived && j.FollowUpAt is null)
|
||||
{
|
||||
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
|
||||
? "No response yet and no follow-up date is scheduled."
|
||||
: $"{reminderReason} No follow-up date is scheduled.";
|
||||
}
|
||||
var workflowSignal = BuildWorkflowSignal(j, d);
|
||||
if (!workflowSignal.NeedsAttention && !upcoming) continue;
|
||||
|
||||
dtos.Add(new JobApplicationDto(
|
||||
Id: j.Id,
|
||||
CompanyId: j.CompanyId,
|
||||
Company: j.Company,
|
||||
JobTitle: j.JobTitle,
|
||||
Status: j.Status,
|
||||
DateApplied: j.DateApplied,
|
||||
ResponseReceived: j.ResponseReceived,
|
||||
ResponseDate: j.ResponseDate,
|
||||
Notes: j.Notes,
|
||||
CoverLetterText: j.CoverLetterText,
|
||||
JobUrl: j.JobUrl,
|
||||
Description: j.Description,
|
||||
TranslatedDescription: j.TranslatedDescription,
|
||||
DescriptionLanguage: j.DescriptionLanguage,
|
||||
Tags: j.Tags,
|
||||
Deadline: j.Deadline,
|
||||
Location: j.Location,
|
||||
Salary: j.Salary,
|
||||
NextAction: j.NextAction,
|
||||
FollowUpAt: j.FollowUpAt,
|
||||
FeedbackRequestedAt: j.FeedbackRequestedAt,
|
||||
HasResume: j.HasResume,
|
||||
HasCoverLetter: j.HasCoverLetter,
|
||||
HasPortfolio: j.HasPortfolio,
|
||||
HasOtherAttachment: j.HasOtherAttachment,
|
||||
IsDeleted: j.IsDeleted,
|
||||
DeletedAt: j.DeletedAt,
|
||||
DaysSince: j.DaysSince,
|
||||
NeedsFollowUp: d.NeedsFollowUp,
|
||||
FollowUpReason: reminderReason,
|
||||
ShortSummary: shortSummary,
|
||||
FullSummary: null
|
||||
));
|
||||
dtos.Add(BuildJobApplicationDto(j, d, followUpReasonOverride: workflowSignal.Reason));
|
||||
}
|
||||
|
||||
// Sort: needsFollowUp first, then nearest followUpAt.
|
||||
@@ -1201,40 +1263,7 @@ namespace JobTrackerApi.Controllers
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg);
|
||||
|
||||
return Ok(new JobApplicationDto(
|
||||
Id: job.Id,
|
||||
CompanyId: job.CompanyId,
|
||||
Company: job.Company,
|
||||
JobTitle: job.JobTitle,
|
||||
Status: job.Status,
|
||||
DateApplied: job.DateApplied,
|
||||
ResponseReceived: job.ResponseReceived,
|
||||
ResponseDate: job.ResponseDate,
|
||||
Notes: job.Notes,
|
||||
CoverLetterText: job.CoverLetterText,
|
||||
JobUrl: job.JobUrl,
|
||||
Description: job.Description,
|
||||
TranslatedDescription: job.TranslatedDescription,
|
||||
DescriptionLanguage: job.DescriptionLanguage,
|
||||
Tags: job.Tags,
|
||||
Deadline: job.Deadline,
|
||||
Location: job.Location,
|
||||
Salary: job.Salary,
|
||||
NextAction: job.NextAction,
|
||||
FollowUpAt: job.FollowUpAt,
|
||||
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
||||
HasResume: job.HasResume,
|
||||
HasCoverLetter: job.HasCoverLetter,
|
||||
HasPortfolio: job.HasPortfolio,
|
||||
HasOtherAttachment: job.HasOtherAttachment,
|
||||
IsDeleted: job.IsDeleted,
|
||||
DeletedAt: job.DeletedAt,
|
||||
DaysSince: job.DaysSince,
|
||||
NeedsFollowUp: followUp.NeedsFollowUp,
|
||||
FollowUpReason: followUp.Reason,
|
||||
ShortSummary: job.ShortSummary,
|
||||
FullSummary: null
|
||||
));
|
||||
return Ok(BuildJobApplicationDto(job, followUp));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
@@ -1648,7 +1677,7 @@ namespace JobTrackerApi.Controllers
|
||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
||||
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
|
||||
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
|
||||
|
||||
private static string BuildPackageModeInstruction(string? mode)
|
||||
{
|
||||
@@ -1916,25 +1945,32 @@ Candidate master CV:
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var settings = await RulesEngine.GetSettings(_db, cancellationToken);
|
||||
var now = DateTime.Now;
|
||||
var lastMessageAt = await _db.Correspondences
|
||||
.AsNoTracking()
|
||||
.Where(c => c.JobApplicationId == id)
|
||||
.MaxAsync(c => (DateTime?)c.Date, cancellationToken);
|
||||
var followUpDecision = RulesEngine.Evaluate(settings, job, now, lastMessageAt);
|
||||
var workflowSignal = BuildWorkflowSignal(job, followUpDecision);
|
||||
|
||||
var completed = new List<string>();
|
||||
var missing = new List<string>();
|
||||
var reminders = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role");
|
||||
if (workflowSignal.HasTailoredCv) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role");
|
||||
if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft");
|
||||
if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example");
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible");
|
||||
if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear");
|
||||
if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date");
|
||||
if (workflowSignal.HasSavedApplicationAnswerDraft) completed.Add("Saved application answers available"); else missing.Add("Save application answers for this role");
|
||||
if (workflowSignal.HasInterviewPrepNotes || !IsInterviewStage(job.Status)) completed.Add("Interview prep notes captured"); else missing.Add("Capture interview prep notes before the interview");
|
||||
|
||||
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV.");
|
||||
if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing.");
|
||||
if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled.");
|
||||
|
||||
var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
|
||||
var reminders = BuildReadinessReminders(job, workflowSignal);
|
||||
var score = Math.Clamp(completed.Count * 12 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
|
||||
var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
|
||||
|
||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders));
|
||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv")]
|
||||
|
||||
Reference in New Issue
Block a user