refactor(api): extract JobApplications DTOs and helpers, fix N+1 aggregation
- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs - GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table - Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back to per-user UserRuleSettings overrides, so a single global cache key would leak settings across users) - Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard, GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan, GetInterviewPrep, GetReadiness) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||||
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None);
|
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None);
|
||||||
|
|
||||||
Assert.IsType<NoContentResult>(result);
|
Assert.IsType<NoContentResult>(result);
|
||||||
var saved = await db.JobApplications.FirstAsync();
|
var saved = await db.JobApplications.FirstAsync();
|
||||||
@@ -135,7 +135,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None);
|
var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<JobApplicationsController.GenerateApplicationPackageDto>(ok.Value);
|
var payload = Assert.IsType<GenerateApplicationPackageDto>(ok.Value);
|
||||||
|
|
||||||
Assert.Contains("Tailored CV", payload.TailoredCvText);
|
Assert.Contains("Tailored CV", payload.TailoredCvText);
|
||||||
Assert.Equal("Cover letter tailored with recruiter context and imported correspondence.", payload.CoverLetterDraft);
|
Assert.Equal("Cover letter tailored with recruiter context and imported correspondence.", payload.CoverLetterDraft);
|
||||||
@@ -261,7 +261,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
|
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(ok.Value);
|
var payload = Assert.IsType<TailoredCvDraftDto>(ok.Value);
|
||||||
Assert.True(payload.IsLegacyFallback);
|
Assert.True(payload.IsLegacyFallback);
|
||||||
Assert.Equal("legacy-text", payload.TemplateId);
|
Assert.Equal("legacy-text", payload.TemplateId);
|
||||||
Assert.Contains("Existing tailored CV text", payload.RenderedText);
|
Assert.Contains("Existing tailored CV text", payload.RenderedText);
|
||||||
@@ -337,14 +337,14 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||||
var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None);
|
var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None);
|
||||||
var generateOk = Assert.IsType<OkObjectResult>(generateResult.Result);
|
var generateOk = Assert.IsType<OkObjectResult>(generateResult.Result);
|
||||||
var generated = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(generateOk.Value);
|
var generated = Assert.IsType<TailoredCvDraftDto>(generateOk.Value);
|
||||||
|
|
||||||
Assert.False(generated.IsLegacyFallback);
|
Assert.False(generated.IsLegacyFallback);
|
||||||
Assert.Equal(7, generated.CanonicalProfileVersion);
|
Assert.Equal(7, generated.CanonicalProfileVersion);
|
||||||
Assert.Equal("Senior Backend Engineer", generated.Headline);
|
Assert.Equal("Senior Backend Engineer", generated.Headline);
|
||||||
Assert.Contains("Led backend API delivery.", generated.RenderedText);
|
Assert.Contains("Led backend API delivery.", generated.RenderedText);
|
||||||
|
|
||||||
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
|
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new SaveTailoredCvDraftRequest(
|
||||||
generated.TemplateId,
|
generated.TemplateId,
|
||||||
"Principal Backend Engineer",
|
"Principal Backend Engineer",
|
||||||
new List<string> { "Own backend delivery for critical APIs." },
|
new List<string> { "Own backend delivery for critical APIs." },
|
||||||
@@ -395,7 +395,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
var renderer = new TestCvTemplateRenderer();
|
var renderer = new TestCvTemplateRenderer();
|
||||||
var exporter = new TestCvPdfExporter();
|
var exporter = new TestCvPdfExporter();
|
||||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1", renderer, exporter);
|
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1", renderer, exporter);
|
||||||
var request = new JobApplicationsController.TailoredCvRenderRequest(
|
var request = new TailoredCvRenderRequest(
|
||||||
"ats-minimal",
|
"ats-minimal",
|
||||||
"Backend Engineer",
|
"Backend Engineer",
|
||||||
new List<string> { "Built APIs" },
|
new List<string> { "Built APIs" },
|
||||||
@@ -409,7 +409,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
|
|
||||||
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
|
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
|
||||||
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
|
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
|
||||||
var preview = Assert.IsType<JobApplicationsController.TailoredCvPreviewDto>(ok.Value);
|
var preview = Assert.IsType<TailoredCvPreviewDto>(ok.Value);
|
||||||
Assert.Equal("ats-minimal", preview.TemplateId);
|
Assert.Equal("ats-minimal", preview.TemplateId);
|
||||||
Assert.Equal("preview.pdf", preview.SuggestedFileName);
|
Assert.Equal("preview.pdf", preview.SuggestedFileName);
|
||||||
Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl);
|
Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public sealed class JobApplicationsControllerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Application_package_record_exposes_expected_fields()
|
public void Application_package_record_exposes_expected_fields()
|
||||||
{
|
{
|
||||||
var type = typeof(JobApplicationsController).GetNestedType("GenerateApplicationPackageDto", BindingFlags.Public | BindingFlags.NonPublic);
|
var type = typeof(GenerateApplicationPackageDto);
|
||||||
Assert.NotNull(type);
|
Assert.NotNull(type);
|
||||||
|
|
||||||
var props = type!.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => x.Name).ToHashSet();
|
var props = type!.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => x.Name).ToHashSet();
|
||||||
@@ -23,7 +23,7 @@ public sealed class JobApplicationsControllerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Save_application_drafts_request_supports_cover_letter_and_notes()
|
public void Save_application_drafts_request_supports_cover_letter_and_notes()
|
||||||
{
|
{
|
||||||
var type = typeof(JobApplicationsController).GetNestedType("SaveApplicationDraftsRequest", BindingFlags.Public | BindingFlags.NonPublic);
|
var type = typeof(SaveApplicationDraftsRequest);
|
||||||
Assert.NotNull(type);
|
Assert.NotNull(type);
|
||||||
|
|
||||||
var ctor = type!.GetConstructors().Single();
|
var ctor = type!.GetConstructors().Single();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, "user-1");
|
var controller = CreateController(db, "user-1");
|
||||||
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
||||||
|
|
||||||
Assert.IsType<NoContentResult>(result);
|
Assert.IsType<NoContentResult>(result);
|
||||||
var saved = await db.JobApplications.FirstAsync();
|
var saved = await db.JobApplications.FirstAsync();
|
||||||
@@ -83,7 +83,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
var dto = Assert.IsType<StatusSuggestionDto>(ok.Value);
|
||||||
Assert.True(dto.HasSuggestion);
|
Assert.True(dto.HasSuggestion);
|
||||||
Assert.Equal("Rejected", dto.SuggestedStatus);
|
Assert.Equal("Rejected", dto.SuggestedStatus);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
var dto = Assert.IsType<StatusSuggestionDto>(ok.Value);
|
||||||
Assert.False(dto.HasSuggestion);
|
Assert.False(dto.HasSuggestion);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(ok.Value);
|
var dto = Assert.IsType<MatchScoreDto>(ok.Value);
|
||||||
Assert.True(dto.HasEnoughSignal);
|
Assert.True(dto.HasEnoughSignal);
|
||||||
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
|
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
|
||||||
Assert.Contains("C#", dto.MatchedKeywords);
|
Assert.Contains("C#", dto.MatchedKeywords);
|
||||||
@@ -181,7 +181,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, "user-1");
|
var controller = CreateController(db, "user-1");
|
||||||
var request = new JobApplicationsController.CreateJobApplicationRequest(
|
var request = new CreateJobApplicationRequest(
|
||||||
JobTitle: "Backend Dev",
|
JobTitle: "Backend Dev",
|
||||||
CompanyId: company.Id,
|
CompanyId: company.Id,
|
||||||
Status: null,
|
Status: null,
|
||||||
@@ -237,7 +237,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, "user-1");
|
var controller = CreateController(db, "user-1");
|
||||||
var request = new JobApplicationsController.UpdateJobApplicationRequest(
|
var request = new UpdateJobApplicationRequest(
|
||||||
JobTitle: "Backend Dev",
|
JobTitle: "Backend Dev",
|
||||||
CompanyId: company.Id,
|
CompanyId: company.Id,
|
||||||
Status: "Applied",
|
Status: "Applied",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public sealed class JobApplicationsFollowUpDraftTests
|
|||||||
var result = await controller.GetFollowUpDraft(job.Id, "waiting-update", null, CancellationToken.None);
|
var result = await controller.GetFollowUpDraft(job.Id, "waiting-update", null, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<JobApplicationsController.FollowUpDraftDto>(ok.Value);
|
var payload = Assert.IsType<FollowUpDraftDto>(ok.Value);
|
||||||
|
|
||||||
Assert.Equal("Re: Backend Developer application update", payload.Subject);
|
Assert.Equal("Re: Backend Developer application update", payload.Subject);
|
||||||
Assert.Contains("Maria", payload.Body);
|
Assert.Contains("Maria", payload.Body);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, "user-1");
|
var controller = CreateController(db, "user-1");
|
||||||
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None);
|
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None);
|
||||||
|
|
||||||
Assert.IsType<NoContentResult>(result);
|
Assert.IsType<NoContentResult>(result);
|
||||||
var saved = await db.JobApplications.FirstAsync();
|
var saved = await db.JobApplications.FirstAsync();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class JobApplicationsWorkflowSignalsTests
|
|||||||
var result = await controller.GetReadiness(job.Id, CancellationToken.None);
|
var result = await controller.GetReadiness(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<JobApplicationsController.ReadinessDto>(ok.Value);
|
var payload = Assert.IsType<ReadinessDto>(ok.Value);
|
||||||
|
|
||||||
Assert.Equal("package-work", payload.WorkflowSignal.ActionKey);
|
Assert.Equal("package-work", payload.WorkflowSignal.ActionKey);
|
||||||
Assert.True(payload.WorkflowSignal.HasPackageGap);
|
Assert.True(payload.WorkflowSignal.HasPackageGap);
|
||||||
@@ -92,7 +92,7 @@ public sealed class JobApplicationsWorkflowSignalsTests
|
|||||||
var result = await controller.GetReminders(14, CancellationToken.None);
|
var result = await controller.GetReminders(14, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<List<JobApplicationsController.JobApplicationDto>>(ok.Value);
|
var payload = Assert.IsType<List<JobApplicationDto>>(ok.Value);
|
||||||
|
|
||||||
var packageReminder = Assert.Single(payload, item => item.Id == packageGapJob.Id);
|
var packageReminder = Assert.Single(payload, item => item.Id == packageGapJob.Id);
|
||||||
Assert.Equal("package-work", packageReminder.WorkflowSignal.ActionKey);
|
Assert.Equal("package-work", packageReminder.WorkflowSignal.ActionKey);
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Controllers
|
||||||
|
{
|
||||||
|
public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName);
|
||||||
|
|
||||||
|
public sealed record TailoredCvRenderRequest(
|
||||||
|
string? TemplateId,
|
||||||
|
string? Headline,
|
||||||
|
List<string>? Summary,
|
||||||
|
List<string>? SelectedSkills,
|
||||||
|
List<TailoredCvExperienceItem>? Experience,
|
||||||
|
List<TailoredCvEducationItem>? Education,
|
||||||
|
List<TailoredCvCustomSection>? CustomSections,
|
||||||
|
TailoredCvRenderOptions? RenderOptions,
|
||||||
|
string? PhotoDataUrl,
|
||||||
|
bool? UseProfileAvatar);
|
||||||
|
|
||||||
|
public sealed record AttachmentContextResult(string Context, List<string> Signals, List<string> UsedFiles);
|
||||||
|
public sealed record CorrespondenceContextResult(string Context, List<string> Signals, List<string> Participants, List<string> ThreadIds);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
|
||||||
|
|
||||||
|
public sealed record JobApplicationDto(
|
||||||
|
int Id,
|
||||||
|
int CompanyId,
|
||||||
|
Company Company,
|
||||||
|
string JobTitle,
|
||||||
|
string Status,
|
||||||
|
DateTime DateApplied,
|
||||||
|
bool ResponseReceived,
|
||||||
|
DateTime? ResponseDate,
|
||||||
|
string? Notes,
|
||||||
|
string? CoverLetterText,
|
||||||
|
string? JobUrl,
|
||||||
|
string? Description,
|
||||||
|
string? TranslatedDescription,
|
||||||
|
string? DescriptionLanguage,
|
||||||
|
string? Tags,
|
||||||
|
DateTime? Deadline,
|
||||||
|
string? Location,
|
||||||
|
string? Salary,
|
||||||
|
decimal? SalaryMin,
|
||||||
|
decimal? SalaryMax,
|
||||||
|
string? SalaryCurrency,
|
||||||
|
string? SalaryPeriod,
|
||||||
|
string? NextAction,
|
||||||
|
DateTime? FollowUpAt,
|
||||||
|
DateTime? FeedbackRequestedAt,
|
||||||
|
bool HasResume,
|
||||||
|
bool HasCoverLetter,
|
||||||
|
bool HasPortfolio,
|
||||||
|
bool HasOtherAttachment,
|
||||||
|
bool IsDeleted,
|
||||||
|
DateTime? DeletedAt,
|
||||||
|
int DaysSince,
|
||||||
|
bool NeedsFollowUp,
|
||||||
|
string? FollowUpReason,
|
||||||
|
string? TailoredCvText,
|
||||||
|
WorkflowSignalDto WorkflowSignal,
|
||||||
|
string? ShortSummary,
|
||||||
|
string? FullSummary
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record CreateJobApplicationRequest(
|
||||||
|
string JobTitle,
|
||||||
|
int CompanyId,
|
||||||
|
string? Status,
|
||||||
|
string? Location,
|
||||||
|
string? Salary,
|
||||||
|
decimal? SalaryMin,
|
||||||
|
decimal? SalaryMax,
|
||||||
|
string? SalaryCurrency,
|
||||||
|
string? SalaryPeriod,
|
||||||
|
string? NextAction,
|
||||||
|
DateTime? FollowUpAt,
|
||||||
|
string? Notes,
|
||||||
|
string? Description,
|
||||||
|
string? TranslatedDescription,
|
||||||
|
string? DescriptionLanguage,
|
||||||
|
string? Tags,
|
||||||
|
DateTime? Deadline,
|
||||||
|
string? CoverLetterText,
|
||||||
|
string? JobUrl,
|
||||||
|
DateTime? DateApplied,
|
||||||
|
DateTime? FeedbackRequestedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateJobApplicationRequest(
|
||||||
|
string JobTitle,
|
||||||
|
int CompanyId,
|
||||||
|
string Status,
|
||||||
|
bool ResponseReceived,
|
||||||
|
DateTime? ResponseDate,
|
||||||
|
string? Location,
|
||||||
|
string? Salary,
|
||||||
|
decimal? SalaryMin,
|
||||||
|
decimal? SalaryMax,
|
||||||
|
string? SalaryCurrency,
|
||||||
|
string? SalaryPeriod,
|
||||||
|
string? NextAction,
|
||||||
|
DateTime? FollowUpAt,
|
||||||
|
string? Notes,
|
||||||
|
string? Description,
|
||||||
|
string? TranslatedDescription,
|
||||||
|
string? DescriptionLanguage,
|
||||||
|
string? Tags,
|
||||||
|
DateTime? Deadline,
|
||||||
|
string? CoverLetterText,
|
||||||
|
string? JobUrl,
|
||||||
|
DateTime? DateApplied,
|
||||||
|
DateTime? FeedbackRequestedAt,
|
||||||
|
DateTime? StatusChangedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateStatusRequest(string Status);
|
||||||
|
|
||||||
|
public sealed record PipelineStageDto(string Key, int Order, string Category);
|
||||||
|
|
||||||
|
public sealed record StatusSuggestionDto(
|
||||||
|
bool HasSuggestion,
|
||||||
|
string? SuggestedStatus,
|
||||||
|
string? CurrentStatus,
|
||||||
|
string? Signal,
|
||||||
|
string? Confidence,
|
||||||
|
DateTime? MessageDate,
|
||||||
|
string? MessageSubject);
|
||||||
|
|
||||||
|
public sealed record FollowUpRequest(DateTime? FollowUpAt);
|
||||||
|
|
||||||
|
public sealed record JobEventDto(int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At);
|
||||||
|
|
||||||
|
public sealed record TimelineItemDto(string Kind, DateTime At, object Data);
|
||||||
|
|
||||||
|
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
|
||||||
|
|
||||||
|
public sealed record TagPoint(string Tag, int Count);
|
||||||
|
|
||||||
|
public sealed record TagTrendSeries(string Tag, List<int> Counts);
|
||||||
|
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||||
|
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
|
||||||
|
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
|
||||||
|
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
|
||||||
|
public sealed record FocusPlanDto(
|
||||||
|
List<string> ImmediatePriorities,
|
||||||
|
List<string> CvBulletIdeas,
|
||||||
|
List<string> ProofPointsToLeadWith,
|
||||||
|
List<string> CoverLetterAngles,
|
||||||
|
List<string> FollowUpApproach,
|
||||||
|
string StrategicSummary);
|
||||||
|
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
|
||||||
|
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
|
||||||
|
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
|
||||||
|
public sealed record CandidateFitDto(
|
||||||
|
string MatchSummary,
|
||||||
|
string FitLevel,
|
||||||
|
int MatchScore,
|
||||||
|
List<string> Strengths,
|
||||||
|
List<string> Gaps,
|
||||||
|
List<string> Mention,
|
||||||
|
List<string> Avoid,
|
||||||
|
List<string> CvImprovements,
|
||||||
|
List<string> MissingKeywords,
|
||||||
|
List<string> InterviewPrep,
|
||||||
|
string TailoredPitch,
|
||||||
|
CandidateFitChannelGuidanceDto Guidance,
|
||||||
|
string? CoverLetterDraft,
|
||||||
|
string? RecruiterMessageDraft);
|
||||||
|
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||||
|
public sealed record TailoredCvDraftDto(
|
||||||
|
int? Id,
|
||||||
|
int? CanonicalProfileVersion,
|
||||||
|
string TemplateId,
|
||||||
|
string? Headline,
|
||||||
|
List<string> Summary,
|
||||||
|
List<string> SelectedSkills,
|
||||||
|
List<TailoredCvExperienceItem> Experience,
|
||||||
|
List<TailoredCvEducationItem> Education,
|
||||||
|
List<TailoredCvCustomSection> CustomSections,
|
||||||
|
TailoredCvRenderOptions RenderOptions,
|
||||||
|
string? GenerationContextHash,
|
||||||
|
DateTimeOffset? LastGeneratedAtUtc,
|
||||||
|
DateTimeOffset? LastEditedAtUtc,
|
||||||
|
string Status,
|
||||||
|
string RenderedText,
|
||||||
|
bool IsLegacyFallback);
|
||||||
|
public sealed record SaveTailoredCvDraftRequest(
|
||||||
|
string? TemplateId,
|
||||||
|
string? Headline,
|
||||||
|
List<string>? Summary,
|
||||||
|
List<string>? SelectedSkills,
|
||||||
|
List<TailoredCvExperienceItem>? Experience,
|
||||||
|
List<TailoredCvEducationItem>? Education,
|
||||||
|
List<TailoredCvCustomSection>? CustomSections,
|
||||||
|
TailoredCvRenderOptions? RenderOptions,
|
||||||
|
string? Status);
|
||||||
|
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
||||||
|
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||||
|
public 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, WorkflowSignalDto WorkflowSignal);
|
||||||
|
|
||||||
|
public sealed record MatchScoreDto(
|
||||||
|
int Score,
|
||||||
|
string Band,
|
||||||
|
int MatchedCount,
|
||||||
|
int TotalKeywords,
|
||||||
|
List<string> MatchedKeywords,
|
||||||
|
List<string> MissingKeywords,
|
||||||
|
List<MatchSectionCoverageDto> SectionCoverage,
|
||||||
|
bool HasEnoughSignal);
|
||||||
|
|
||||||
|
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,34 +22,45 @@ namespace JobTrackerApi.Services
|
|||||||
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
|
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
|
var last30 = now.AddDays(-30);
|
||||||
|
|
||||||
// Project to only the columns the stats need instead of materialising full
|
// Aggregate server-side (COUNT/GROUP BY) instead of pulling every row into memory.
|
||||||
// JobApplication rows (which drag large Description/TranslatedDescription/
|
var total = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken);
|
||||||
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
|
var active = await _db.JobApplications.AsNoTracking().CountAsync(j => !j.IsDeleted, cancellationToken);
|
||||||
// per-tenant set.
|
var appliedLast30Days = await _db.JobApplications.AsNoTracking()
|
||||||
var all = await _db.JobApplications
|
.CountAsync(j => !j.IsDeleted && j.DateApplied >= last30, cancellationToken);
|
||||||
|
|
||||||
|
var byStatus = await _db.JobApplications
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
|
.Where(j => !j.IsDeleted)
|
||||||
|
.GroupBy(j => j.Status)
|
||||||
|
.Select(g => new { Status = g.Key, Count = g.Count() })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var active = all.Where(j => !j.IsDeleted).ToList();
|
var byStatusDict = byStatus
|
||||||
|
.GroupBy(x => string.IsNullOrWhiteSpace(x.Status) ? "Unknown" : x.Status)
|
||||||
|
.OrderByDescending(g => g.Sum(x => x.Count))
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
|
||||||
|
|
||||||
var byStatus = active
|
// ponytail: average age needs a per-row day-diff that doesn't translate identically
|
||||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
|
// across the SQLite/MySQL providers this app runs on, so pull just the DateApplied
|
||||||
.OrderByDescending(g => g.Count())
|
// column (no wide blob columns) for active rows and average client-side.
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
var activeDates = active == 0
|
||||||
|
? new List<DateTime>()
|
||||||
|
: await _db.JobApplications.AsNoTracking()
|
||||||
|
.Where(j => !j.IsDeleted)
|
||||||
|
.Select(j => j.DateApplied)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
|
var avgDays = activeDates.Count == 0
|
||||||
|
|
||||||
var avgDays = active.Count == 0
|
|
||||||
? 0
|
? 0
|
||||||
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
|
: activeDates.Average(d => Math.Max(0, (now - d).TotalDays));
|
||||||
|
|
||||||
return new JobStats(
|
return new JobStats(
|
||||||
Total: all.Count,
|
Total: total,
|
||||||
Active: active.Count,
|
Active: active,
|
||||||
Deleted: all.Count - active.Count,
|
Deleted: total - active,
|
||||||
ByStatus: byStatus,
|
ByStatus: byStatusDict,
|
||||||
AppliedLast30Days: appliedLast30Days,
|
AppliedLast30Days: appliedLast30Days,
|
||||||
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
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.Deserialize(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.Deserialize(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 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;
|
||||||
|
|
||||||
|
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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user