From ea6c3650f3f1a46921c28ef19c3f3c5a1e9a1b11 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 20:08:59 +0200 Subject: [PATCH] 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 --- .../JobApplicationsApplicationPackageTests.cs | 14 +- .../JobApplicationsControllerTests.cs | 4 +- .../JobApplicationsEndpointBehaviorTests.cs | 12 +- .../JobApplicationsFollowUpDraftTests.cs | 2 +- .../JobApplicationsMariaDraftTests.cs | 2 +- .../JobApplicationsWorkflowSignalsTests.cs | 4 +- .../Controllers/JobApplicationDtos.cs | 228 +++++ .../Controllers/JobApplicationsController.cs | 874 +----------------- JobTrackerApi/Services/AnalyticsService.cs | 49 +- .../Services/JobApplicationHelpers.cs | 622 +++++++++++++ 10 files changed, 936 insertions(+), 875 deletions(-) create mode 100644 JobTrackerApi/Controllers/JobApplicationDtos.cs create mode 100644 JobTrackerApi/Services/JobApplicationHelpers.cs diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index c684925..da5ec53 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "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(result); 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 ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Contains("Tailored CV", payload.TailoredCvText); 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 ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.True(payload.IsLegacyFallback); Assert.Equal("legacy-text", payload.TemplateId); 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 generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None); var generateOk = Assert.IsType(generateResult.Result); - var generated = Assert.IsType(generateOk.Value); + var generated = Assert.IsType(generateOk.Value); Assert.False(generated.IsLegacyFallback); Assert.Equal(7, generated.CanonicalProfileVersion); Assert.Equal("Senior Backend Engineer", generated.Headline); 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, "Principal Backend Engineer", new List { "Own backend delivery for critical APIs." }, @@ -395,7 +395,7 @@ public sealed class JobApplicationsApplicationPackageTests var renderer = new TestCvTemplateRenderer(); var exporter = new TestCvPdfExporter(); var controller = CreateController(db, Mock.Of(), "user-1", renderer, exporter); - var request = new JobApplicationsController.TailoredCvRenderRequest( + var request = new TailoredCvRenderRequest( "ats-minimal", "Backend Engineer", new List { "Built APIs" }, @@ -409,7 +409,7 @@ public sealed class JobApplicationsApplicationPackageTests var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None); var ok = Assert.IsType(previewResult.Result); - var preview = Assert.IsType(ok.Value); + var preview = Assert.IsType(ok.Value); Assert.Equal("ats-minimal", preview.TemplateId); Assert.Equal("preview.pdf", preview.SuggestedFileName); Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl); diff --git a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs index cde2de6..d774d2f 100644 --- a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs @@ -9,7 +9,7 @@ public sealed class JobApplicationsControllerTests [Fact] 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); var props = type!.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => x.Name).ToHashSet(); @@ -23,7 +23,7 @@ public sealed class JobApplicationsControllerTests [Fact] 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); var ctor = type!.GetConstructors().Single(); diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 48aab5e..b55eb66 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests await db.SaveChangesAsync(); 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(result); var saved = await db.JobApplications.FirstAsync(); @@ -83,7 +83,7 @@ public sealed class JobApplicationsEndpointBehaviorTests var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); - var dto = Assert.IsType(ok.Value); + var dto = Assert.IsType(ok.Value); Assert.True(dto.HasSuggestion); Assert.Equal("Rejected", dto.SuggestedStatus); } @@ -114,7 +114,7 @@ public sealed class JobApplicationsEndpointBehaviorTests var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); - var dto = Assert.IsType(ok.Value); + var dto = Assert.IsType(ok.Value); Assert.False(dto.HasSuggestion); } @@ -147,7 +147,7 @@ public sealed class JobApplicationsEndpointBehaviorTests var result = await controller.GetMatchScore(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); - var dto = Assert.IsType(ok.Value); + var dto = Assert.IsType(ok.Value); Assert.True(dto.HasEnoughSignal); Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}"); Assert.Contains("C#", dto.MatchedKeywords); @@ -181,7 +181,7 @@ public sealed class JobApplicationsEndpointBehaviorTests await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); - var request = new JobApplicationsController.CreateJobApplicationRequest( + var request = new CreateJobApplicationRequest( JobTitle: "Backend Dev", CompanyId: company.Id, Status: null, @@ -237,7 +237,7 @@ public sealed class JobApplicationsEndpointBehaviorTests await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); - var request = new JobApplicationsController.UpdateJobApplicationRequest( + var request = new UpdateJobApplicationRequest( JobTitle: "Backend Dev", CompanyId: company.Id, Status: "Applied", diff --git a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs index 01b6cb3..749e179 100644 --- a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs @@ -92,7 +92,7 @@ public sealed class JobApplicationsFollowUpDraftTests var result = await controller.GetFollowUpDraft(job.Id, "waiting-update", null, CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal("Re: Backend Developer application update", payload.Subject); Assert.Contains("Maria", payload.Body); diff --git a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs index cf7b58c..db01cb9 100644 --- a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs @@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests await db.SaveChangesAsync(); 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(result); var saved = await db.JobApplications.FirstAsync(); diff --git a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs index 126d569..17891ea 100644 --- a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs @@ -43,7 +43,7 @@ public sealed class JobApplicationsWorkflowSignalsTests var result = await controller.GetReadiness(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal("package-work", payload.WorkflowSignal.ActionKey); Assert.True(payload.WorkflowSignal.HasPackageGap); @@ -92,7 +92,7 @@ public sealed class JobApplicationsWorkflowSignalsTests var result = await controller.GetReminders(14, CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType>(ok.Value); + var payload = Assert.IsType>(ok.Value); var packageReminder = Assert.Single(payload, item => item.Id == packageGapJob.Id); Assert.Equal("package-work", packageReminder.WorkflowSignal.ActionKey); diff --git a/JobTrackerApi/Controllers/JobApplicationDtos.cs b/JobTrackerApi/Controllers/JobApplicationDtos.cs new file mode 100644 index 0000000..cd4cbaa --- /dev/null +++ b/JobTrackerApi/Controllers/JobApplicationDtos.cs @@ -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? Summary, + List? SelectedSkills, + List? Experience, + List? Education, + List? CustomSections, + TailoredCvRenderOptions? RenderOptions, + string? PhotoDataUrl, + bool? UseProfileAvatar); + + public sealed record AttachmentContextResult(string Context, List Signals, List UsedFiles); + public sealed record CorrespondenceContextResult(string Context, List Signals, List Participants, List 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(List 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 Counts); + public sealed record TagTrendPoint(string Month, List 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 Matches); + public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt); + public sealed record FocusPlanDto( + List ImmediatePriorities, + List CvBulletIdeas, + List ProofPointsToLeadWith, + List CoverLetterAngles, + List FollowUpApproach, + string StrategicSummary); + public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt); + public sealed record TagTrendResponse(List Months, List Series); + public sealed record CandidateFitChannelGuidanceDto(List Cv, List CoverLetter, List Interview, List RecruiterMessage); + public sealed record CandidateFitDto( + string MatchSummary, + string FitLevel, + int MatchScore, + List Strengths, + List Gaps, + List Mention, + List Avoid, + List CvImprovements, + List MissingKeywords, + List 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 Summary, + List SelectedSkills, + List Experience, + List Education, + List CustomSections, + TailoredCvRenderOptions RenderOptions, + string? GenerationContextHash, + DateTimeOffset? LastGeneratedAtUtc, + DateTimeOffset? LastEditedAtUtc, + string Status, + string RenderedText, + bool IsLegacyFallback); + public sealed record SaveTailoredCvDraftRequest( + string? TemplateId, + string? Headline, + List? Summary, + List? SelectedSkills, + List? Experience, + List? Education, + List? CustomSections, + TailoredCvRenderOptions? RenderOptions, + string? Status); + public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints, List AttachmentSignals, List AttachmentFilesUsed, List CoverLetterVariants, List 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 TalkingPoints, List LikelyQuestions, List WeakSpots); + public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders, WorkflowSignalDto WorkflowSignal); + + public sealed record MatchScoreDto( + int Score, + string Band, + int MatchedCount, + int TotalKeywords, + List MatchedKeywords, + List MissingKeywords, + List SectionCoverage, + bool HasEnoughSignal); + + public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total); +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 94f5dac..0979c85 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; @@ -9,6 +10,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Identity; +using static JobTrackerApi.Services.JobApplicationHelpers; namespace JobTrackerApi.Controllers { @@ -25,8 +27,9 @@ namespace JobTrackerApi.Controllers private readonly ICvPdfExporter _cvPdfExporter; private readonly AnalyticsService _analytics; private readonly IJobCvMatchService _matchService; + private readonly IMemoryCache _cache; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null) { _db = db; _summarizer = summarizer; @@ -37,6 +40,21 @@ namespace JobTrackerApi.Controllers _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); _analytics = analytics ?? new AnalyticsService(db); _matchService = matchService ?? new JobCvMatchService(); + _cache = cache ?? new MemoryCache(new MemoryCacheOptions()); + } + + // ponytail: RulesEngine.GetSettings is per-user (falls back to the global RuleSettings + // singleton only when the user has no override), so the cache key must include the + // current user id -- a single global key would leak one user's follow-up rules to another. + private async Task GetCachedRuleSettingsAsync(CancellationToken cancellationToken) + { + var cacheKey = $"rulesettings:{_db.CurrentUserId ?? "anon"}"; + var cached = await _cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30); + return await RulesEngine.GetSettings(_db, cancellationToken); + }); + return cached!; } private sealed class ThrowingCvPdfExporter : ICvPdfExporter @@ -57,246 +75,6 @@ namespace JobTrackerApi.Controllers return await _users.FindByIdAsync(userId); } - private 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"; - } - - private 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,"; - } - - private static string BuildStructuredCvContext(ApplicationUser? user) - { - var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); - var blocks = new List(); - - var contactLines = new List(); - if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}"); - if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}"); - if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}"); - if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}"); - if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}"); - if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}"); - - if (structured.Summary.Count > 0) - { - blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}"); - } - - if (structured.Skills.Count > 0) - { - blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}"); - } - - if (structured.Jobs.Count > 0) - { - var jobBlocks = structured.Jobs.Take(3).Select(job => - { - var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value))); - var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}"); - return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value))); - }).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); - if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}"); - } - - if (structured.Education.Count > 0) - { - var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))); - blocks.Add($"Education:\n- {string.Join("\n- ", items)}"); - } - - if (structured.Languages.Count > 0) - { - var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))); - blocks.Add($"Languages:\n- {string.Join("\n- ", items)}"); - } - - if (structured.OtherSections.Count > 0) - { - var items = structured.OtherSections.Take(2) - .Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0) - .Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}") - .ToList(); - if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}"); - } - - if (blocks.Count == 0 && structured.Sections.Count > 0) - { - blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}")); - } - - return blocks.Count > 0 - ? $"Structured CV:\n{string.Join("\n\n", blocks)}" - : string.Empty; - } - - private static string BuildCvSearchCorpus(ApplicationUser? user) - { - var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); - var parts = new List(); - if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!); - if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!); - if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary)); - if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills)); - if (structured.Jobs.Count > 0) - { - parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills)))); - } - if (structured.Education.Count > 0) - { - parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details)))); - } - if (structured.Languages.Count > 0) - { - parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value)))))); - } - return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part))); - } - - private 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")}"; - } - - private static string ComputeGenerationContextHash(string value) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); - return Convert.ToHexString(bytes).ToLowerInvariant(); - } - - private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable matchedTags) - { - var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) } - .Where(value => !string.IsNullOrWhiteSpace(value))) - .ToLowerInvariant(); - var score = 0; - foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag))) - { - if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4; - } - score += Math.Min(job.Bullets.Count, 4); - return score; - } - - private static List SelectTailoredSkills(StructuredCvProfile structured, string jobText) - { - var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - var prioritized = structured.Skills - .Select(skill => new - { - Skill = skill, - Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0 - }) - .OrderByDescending(entry => entry.Score) - .ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) - .Select(entry => entry.Skill) - .ToList(); - - if (prioritized.Count == 0) - { - prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - } - - return prioritized.Take(10).ToList(); - } - - private static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job) - { - var text = (job.TailoredCvText ?? string.Empty).Trim(); - var document = new TailoredCvDocument - { - Headline = job.JobTitle, - CustomSections = string.IsNullOrWhiteSpace(text) - ? new List() - : new List - { - new TailoredCvCustomSection - { - Title = "Legacy draft text", - Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(), - } - } - }; - return TailoredCvDraftJson.Normalize(document); - } - - private 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); - } - - private 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); - } - - private 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, - }); - } - private async Task FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken) { return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken); @@ -313,19 +91,6 @@ namespace JobTrackerApi.Controllers photoDataUrl); } - public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName); - public sealed record TailoredCvRenderRequest( - string? TemplateId, - string? Headline, - List? Summary, - List? SelectedSkills, - List? Experience, - List? Education, - List? CustomSections, - TailoredCvRenderOptions? RenderOptions, - string? PhotoDataUrl, - bool? UseProfileAvatar); - private async Task UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken) { var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -460,12 +225,6 @@ Canonical profile: }; } - private sealed record AttachmentContextResult(string Context, List Signals, List UsedFiles); - private sealed record CorrespondenceContextResult(string Context, List Signals, List Participants, List ThreadIds); - - private const string ApplicationAnswerDraftStart = "<<>>"; - private const string ApplicationAnswerDraftEnd = "<<>>"; - private async Task BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null) { HashSet? allowedIds = null; @@ -625,89 +384,6 @@ Canonical profile: return new CorrespondenceContextResult(context.ToString().Trim(), signals, participants, threadIds); } - private 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; - } - - private 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"; - } - - private static List BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer) - { - var signals = new List(); - - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}"); - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}"); - if (lastMessage is not null) - { - signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}"); - } - if (correspondenceContext?.Participants.Count > 0) - { - signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}"); - } - if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available"); - if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available"); - if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available"); - if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available"); - - if (correspondenceContext is not null) - { - foreach (var signal in correspondenceContext.Signals) - { - if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal); - } - } - - return signals.Take(6).ToList(); - } - - private 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, - }; - } - private async Task> BuildDraftVariantsAsync(string baseInstruction, string context, CancellationToken cancellationToken, params string[] styles) { var variants = new List(); @@ -775,342 +451,6 @@ Canonical profile: FullSummary: fullSummary); } - private static List BuildFollowUpApproach(string status, List matchedTags, List missingTags) - { - var normalized = (status ?? string.Empty).Trim(); - var advice = new List(); - - switch (normalized) - { - case "Applied": - advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied."); - advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background."); - break; - case "Waiting": - advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific."); - advice.Add("Use one proof point that shows why you remain a strong fit."); - break; - case "Interview": - case "Interviewing": - advice.Add("Focus on momentum, appreciation, and readiness for the next step."); - advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible."); - break; - case "Offer": - advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing."); - advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress."); - break; - case "Rejected": - advice.Add("If appropriate, ask for feedback with a respectful and concise tone."); - advice.Add("Keep the door open for future opportunities instead of arguing the decision."); - break; - default: - advice.Add("Match the tone to the current stage and be specific about why you are following up now."); - advice.Add("Keep it concise, credible, and easy to respond to."); - break; - } - - if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}."); - if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly."); - - return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList(); - } - - private static IEnumerable SplitTags(string? s) - { - if (string.IsNullOrWhiteSpace(s)) yield break; - - var trimmed = s.Trim(); - - List? jsonTags = null; - if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) - { - try - { - jsonTags = JsonSerializer.Deserialize>(trimmed); - } - catch - { - jsonTags = null; - } - } - - if (jsonTags is not null) - { - foreach (var x in jsonTags) - { - var t = (x ?? string.Empty).Trim(); - if (t.Length == 0) continue; - yield return t; - } - yield break; - } - - foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) - { - var t = raw.Trim(); - if (t.Length == 0) continue; - yield return t; - } - } - - private static string NormalizeForComparison(string value) - { - if (string.IsNullOrWhiteSpace(value)) return string.Empty; - return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); - } - - private 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())); - } - - private 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); - } - - private 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; - } - - 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 BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal) - { - var reminders = new List(); - - if (workflowSignal.HasPackageGap) - { - reminders.Add(workflowSignal.HasTailoredCv - ? "Saved application answers are still missing from the package." - : workflowSignal.HasSavedApplicationAnswerDraft - ? "This role is active but still missing a tailored CV." - : "This role is active but still needs a tailored CV and saved application answers."); - } - - if (workflowSignal.NeedsInterviewPrep) - { - reminders.Add("Interview stage reached but prep notes are still missing."); - } - - if (workflowSignal.NeedsFollowUpAction) - { - reminders.Add(job.FollowUpAt is null - ? "No response yet and no follow-up is scheduled." - : workflowSignal.Reason); - } - - return reminders - .Where(reminder => !string.IsNullOrWhiteSpace(reminder)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - public sealed record PagedResult(List 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 - ); - [HttpGet] public async Task>> GetAll( [FromQuery] int page = 1, @@ -1131,6 +471,7 @@ Canonical profile: if (pageSize is not (15 or 20 or 25)) pageSize = 15; var query = _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .AsQueryable(); @@ -1175,7 +516,7 @@ Canonical profile: query = query.Where(j => j.Location != null && EF.Functions.Like(j.Location, like)); } - var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var settings = await GetCachedRuleSettingsAsync(cancellationToken); var now = DateTime.Now; var lastMsg = await _db.Correspondences @@ -1262,12 +603,13 @@ Canonical profile: public async Task> GetById([FromRoute] int id, CancellationToken cancellationToken) { var job = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); - var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var settings = await GetCachedRuleSettingsAsync(cancellationToken); var now = DateTime.Now; var lm = await _db.Correspondences .AsNoTracking() @@ -1289,6 +631,7 @@ Canonical profile: ) { var query = _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .AsQueryable(); @@ -1310,7 +653,7 @@ Canonical profile: if (upcomingDays < 1) upcomingDays = 1; if (upcomingDays > 90) upcomingDays = 90; - var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var settings = await GetCachedRuleSettingsAsync(cancellationToken); var now = DateTime.Now; var upcomingTo = now.AddDays(upcomingDays); @@ -1321,6 +664,7 @@ Canonical profile: .ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken); var candidates = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .Where(j => !j.IsDeleted) .Where(j => @@ -1355,30 +699,6 @@ Canonical profile: return Ok(dtos); } - 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 - ); - private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary( decimal? min, decimal? max, string? currency, string? period) { @@ -1467,33 +787,6 @@ Canonical profile: return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); } - 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 - ); - [HttpPut("{id:int}")] public async Task Update([FromRoute] int id, [FromBody] UpdateJobApplicationRequest request, CancellationToken cancellationToken) { @@ -1559,10 +852,6 @@ Canonical profile: return NoContent(); } - public sealed record UpdateStatusRequest(string Status); - - public sealed record PipelineStageDto(string Key, int Order, string Category); - /// Canonical ordered pipeline stages so the UI renders one source of truth. [HttpGet("pipeline")] public ActionResult> GetPipeline() @@ -1593,15 +882,6 @@ Canonical profile: return NoContent(); } - public sealed record StatusSuggestionDto( - bool HasSuggestion, - string? SuggestedStatus, - string? CurrentStatus, - string? Signal, - string? Confidence, - DateTime? MessageDate, - string? MessageSubject); - /// /// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview /// invite or rejection). Deterministic and always human-confirmed via PATCH .../status. @@ -1609,7 +889,7 @@ Canonical profile: [HttpGet("{id:int}/status-suggestion")] public async Task> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken) { - var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + var job = await _db.JobApplications.AsNoTracking().FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null); @@ -1678,7 +958,7 @@ Canonical profile: await _db.SaveChangesAsync(cancellationToken); - var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var settings = await GetCachedRuleSettingsAsync(cancellationToken); var lastMsg = await _db.Correspondences .AsNoTracking() .Where(c => c.JobApplicationId == id) @@ -1734,8 +1014,6 @@ Canonical profile: return NoContent(); } - public sealed record FollowUpRequest(DateTime? FollowUpAt); - [HttpPatch("{id:int}/followup")] public async Task SetFollowUp([FromRoute] int id, [FromBody] FollowUpRequest request, CancellationToken cancellationToken) { @@ -1757,8 +1035,6 @@ Canonical profile: return NoContent(); } - public sealed record JobEventDto(int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At); - [HttpGet("{id:int}/history")] public async Task>> GetHistory([FromRoute] int id, CancellationToken cancellationToken) { @@ -1775,8 +1051,6 @@ Canonical profile: return Ok(items); } - public sealed record TimelineItemDto(string Kind, DateTime At, object Data); - [HttpGet("{id:int}/timeline")] public async Task>> GetTimeline([FromRoute] int id, CancellationToken cancellationToken) { @@ -1825,7 +1099,6 @@ Canonical profile: [HttpGet("stats")] public async Task> GetStats(CancellationToken cancellationToken) => Ok(await _analytics.GetStatsAsync(cancellationToken)); - public sealed record AnalyticsPoint(string Month, int Applied, int Responses); [HttpGet("analytics")] public async Task>> GetAnalytics( @@ -1913,8 +1186,6 @@ Canonical profile: return Ok(outList); } - public sealed record TagPoint(string Tag, int Count); - [HttpGet("tags")] public async Task>> GetTags( [FromQuery] int limit = 10, @@ -2021,70 +1292,6 @@ Canonical profile: return Ok(outList); } - public sealed record TagTrendSeries(string Tag, List Counts); - public sealed record TagTrendPoint(string Month, List 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 Matches); - public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt); - public sealed record FocusPlanDto( - List ImmediatePriorities, - List CvBulletIdeas, - List ProofPointsToLeadWith, - List CoverLetterAngles, - List FollowUpApproach, - string StrategicSummary); - public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt); - public sealed record TagTrendResponse(List Months, List Series); - public sealed record CandidateFitChannelGuidanceDto(List Cv, List CoverLetter, List Interview, List RecruiterMessage); - public sealed record CandidateFitDto( - string MatchSummary, - string FitLevel, - int MatchScore, - List Strengths, - List Gaps, - List Mention, - List Avoid, - List CvImprovements, - List MissingKeywords, - List 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 Summary, - List SelectedSkills, - List Experience, - List Education, - List CustomSections, - TailoredCvRenderOptions RenderOptions, - string? GenerationContextHash, - DateTimeOffset? LastGeneratedAtUtc, - DateTimeOffset? LastEditedAtUtc, - string Status, - string RenderedText, - bool IsLegacyFallback); - public sealed record SaveTailoredCvDraftRequest( - string? TemplateId, - string? Headline, - List? Summary, - List? SelectedSkills, - List? Experience, - List? Education, - List? CustomSections, - TailoredCvRenderOptions? RenderOptions, - string? Status); - public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints, List AttachmentSignals, List AttachmentFilesUsed, List CoverLetterVariants, List RecruiterMessageVariants); - 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 TalkingPoints, List LikelyQuestions, List WeakSpots); - public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders, WorkflowSignalDto WorkflowSignal); - private static string BuildPackageModeInstruction(string? mode) { return (mode ?? string.Empty).Trim().ToLowerInvariant() switch @@ -2108,18 +1315,6 @@ Canonical profile: }; } - public sealed record MatchScoreDto( - int Score, - string Band, - int MatchedCount, - int TotalKeywords, - List MatchedKeywords, - List MissingKeywords, - List SectionCoverage, - bool HasEnoughSignal); - - public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total); - // Builds CV text grouped by section so match coverage can show *where* the evidence sits. private static Dictionary BuildCvSections(ApplicationUser? user) { @@ -2157,6 +1352,7 @@ Canonical profile: public async Task> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken) { var job = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); @@ -2164,7 +1360,7 @@ Canonical profile: var userId = CurrentUserId; if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); var cvSections = BuildCvSections(user); if (cvSections.Count == 0) { @@ -2195,6 +1391,7 @@ Canonical profile: public async Task> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); @@ -2202,7 +1399,7 @@ Canonical profile: var userId = CurrentUserId; if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); var cvText = user?.ProfileCvText; if (string.IsNullOrWhiteSpace(cvText)) { @@ -2319,6 +1516,7 @@ Candidate CV/profile: public async Task> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); @@ -2326,7 +1524,7 @@ Candidate CV/profile: var userId = CurrentUserId; if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); var cvText = user?.ProfileCvText; if (string.IsNullOrWhiteSpace(cvText)) { @@ -2401,6 +1599,7 @@ Candidate master CV: public async Task> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); @@ -2430,11 +1629,12 @@ Candidate master CV: public async Task> GetReadiness([FromRoute] int id, CancellationToken cancellationToken) { var job = await _db.JobApplications + .AsNoTracking() .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); - var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var settings = await GetCachedRuleSettingsAsync(cancellationToken); var now = DateTime.Now; var lastMessageAt = await _db.Correspondences .AsNoTracking() diff --git a/JobTrackerApi/Services/AnalyticsService.cs b/JobTrackerApi/Services/AnalyticsService.cs index 3f2c342..06ddffd 100644 --- a/JobTrackerApi/Services/AnalyticsService.cs +++ b/JobTrackerApi/Services/AnalyticsService.cs @@ -22,34 +22,45 @@ namespace JobTrackerApi.Services public async Task GetStatsAsync(CancellationToken cancellationToken) { var now = DateTime.Now; + var last30 = now.AddDays(-30); - // Project to only the columns the stats need instead of materialising full - // JobApplication rows (which drag large Description/TranslatedDescription/ - // TailoredCvText/Notes blobs). Aggregation stays in memory over a small - // per-tenant set. - var all = await _db.JobApplications + // Aggregate server-side (COUNT/GROUP BY) instead of pulling every row into memory. + var total = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken); + var active = await _db.JobApplications.AsNoTracking().CountAsync(j => !j.IsDeleted, cancellationToken); + var appliedLast30Days = await _db.JobApplications.AsNoTracking() + .CountAsync(j => !j.IsDeleted && j.DateApplied >= last30, cancellationToken); + + var byStatus = await _db.JobApplications .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); - 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 - .GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status) - .OrderByDescending(g => g.Count()) - .ToDictionary(g => g.Key, g => g.Count()); + // ponytail: average age needs a per-row day-diff that doesn't translate identically + // across the SQLite/MySQL providers this app runs on, so pull just the DateApplied + // column (no wide blob columns) for active rows and average client-side. + var activeDates = active == 0 + ? new List() + : 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 = active.Count == 0 + var avgDays = activeDates.Count == 0 ? 0 - : active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays)); + : activeDates.Average(d => Math.Max(0, (now - d).TotalDays)); return new JobStats( - Total: all.Count, - Active: active.Count, - Deleted: all.Count - active.Count, - ByStatus: byStatus, + Total: total, + Active: active, + Deleted: total - active, + ByStatus: byStatusDict, AppliedLast30Days: appliedLast30Days, AverageDaysSinceApplied: Math.Round(avgDays, 1) ); diff --git a/JobTrackerApi/Services/JobApplicationHelpers.cs b/JobTrackerApi/Services/JobApplicationHelpers.cs new file mode 100644 index 0000000..5284c3f --- /dev/null +++ b/JobTrackerApi/Services/JobApplicationHelpers.cs @@ -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 +{ + /// + /// Pure, stateless helpers extracted from JobApplicationsController. None of these touch + /// the database, AI services, or other instance state -- same inputs always produce the + /// same outputs, so they are safe to share as static methods. + /// + public static class JobApplicationHelpers + { + private const string ApplicationAnswerDraftStart = "<<>>"; + private const string ApplicationAnswerDraftEnd = "<<>>"; + + public static string GetPreferredDisplayName(ApplicationUser? user) + { + if (user is null) return "Your Name"; + if (!string.IsNullOrWhiteSpace(user.DisplayName)) return user.DisplayName.Trim(); + var fullName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (!string.IsNullOrWhiteSpace(fullName)) return fullName; + if (!string.IsNullOrWhiteSpace(user.UserName)) return user.UserName.Trim(); + if (!string.IsNullOrWhiteSpace(user.Email)) return user.Email.Trim(); + return "Your Name"; + } + + public static string BuildGreeting(JobApplication job) + { + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) return $"Hi {job.Company.RecruiterName.Trim()},"; + if (!string.IsNullOrWhiteSpace(job.Company?.Name)) return $"Hi {job.Company.Name.Trim()} team,"; + return "Hi there,"; + } + + public static string BuildStructuredCvContext(ApplicationUser? user) + { + var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); + var blocks = new List(); + + var contactLines = new List(); + if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}"); + if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}"); + if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}"); + if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}"); + if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}"); + if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}"); + + if (structured.Summary.Count > 0) + { + blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}"); + } + + if (structured.Skills.Count > 0) + { + blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}"); + } + + if (structured.Jobs.Count > 0) + { + var jobBlocks = structured.Jobs.Take(3).Select(job => + { + var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value))); + var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}"); + return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value))); + }).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); + if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}"); + } + + if (structured.Education.Count > 0) + { + var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))); + blocks.Add($"Education:\n- {string.Join("\n- ", items)}"); + } + + if (structured.Languages.Count > 0) + { + var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))); + blocks.Add($"Languages:\n- {string.Join("\n- ", items)}"); + } + + if (structured.OtherSections.Count > 0) + { + var items = structured.OtherSections.Take(2) + .Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0) + .Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}") + .ToList(); + if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}"); + } + + if (blocks.Count == 0 && structured.Sections.Count > 0) + { + blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}")); + } + + return blocks.Count > 0 + ? $"Structured CV:\n{string.Join("\n\n", blocks)}" + : string.Empty; + } + + public static string BuildCvSearchCorpus(ApplicationUser? user) + { + var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); + var parts = new List(); + if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!); + if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!); + if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary)); + if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills)); + if (structured.Jobs.Count > 0) + { + parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills)))); + } + if (structured.Education.Count > 0) + { + parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details)))); + } + if (structured.Languages.Count > 0) + { + parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value)))))); + } + return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part))); + } + + public static string? FormatStructuredDateRange(string? start, string? end, bool isCurrent) + { + if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null; + if (string.IsNullOrWhiteSpace(start)) return end; + return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}"; + } + + public static string ComputeGenerationContextHash(string value) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable matchedTags) + { + var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) } + .Where(value => !string.IsNullOrWhiteSpace(value))) + .ToLowerInvariant(); + var score = 0; + foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag))) + { + if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4; + } + score += Math.Min(job.Bullets.Count, 4); + return score; + } + + public static List SelectTailoredSkills(StructuredCvProfile structured, string jobText) + { + var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var prioritized = structured.Skills + .Select(skill => new + { + Skill = skill, + Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0 + }) + .OrderByDescending(entry => entry.Score) + .ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) + .Select(entry => entry.Skill) + .ToList(); + + if (prioritized.Count == 0) + { + prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + return prioritized.Take(10).ToList(); + } + + public static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job) + { + var text = (job.TailoredCvText ?? string.Empty).Trim(); + var document = new TailoredCvDocument + { + Headline = job.JobTitle, + CustomSections = string.IsNullOrWhiteSpace(text) + ? new List() + : new List + { + new TailoredCvCustomSection + { + Title = "Legacy draft text", + Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(), + } + } + }; + return TailoredCvDraftJson.Normalize(document); + } + + public static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft) + { + var document = TailoredCvDraftJson.FromDraft(draft); + return new TailoredCvDraftDto( + draft.Id, + draft.CanonicalProfileVersion, + draft.TemplateId, + document.Headline, + document.Summary, + document.SelectedSkills, + document.Experience, + document.Education, + document.CustomSections, + document.RenderOptions, + draft.GenerationContextHash, + draft.LastGeneratedAtUtc, + draft.LastEditedAtUtc, + draft.Status, + TailoredCvDraftJson.RenderPlainText(document), + false); + } + + public static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job) + { + var document = BuildLegacyTailoredCvFallback(job); + return new TailoredCvDraftDto( + null, + null, + "legacy-text", + document.Headline, + document.Summary, + document.SelectedSkills, + document.Experience, + document.Education, + document.CustomSections, + document.RenderOptions, + null, + null, + job.TailoredCvUpdatedAt, + string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import", + TailoredCvDraftJson.RenderPlainText(document), + true); + } + + public static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job) + { + var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job); + if (request is null) + { + return baseDocument; + } + + return TailoredCvDraftJson.Normalize(new TailoredCvDocument + { + TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal", + Headline = request.Headline ?? baseDocument.Headline, + Summary = request.Summary ?? baseDocument.Summary, + SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills, + Experience = request.Experience ?? baseDocument.Experience, + Education = request.Education ?? baseDocument.Education, + CustomSections = request.CustomSections ?? baseDocument.CustomSections, + RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions, + }); + } + + public static string? ExtractSavedApplicationAnswerDraft(string? notes) + { + var value = (notes ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) return null; + + var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); + var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); + if (startIndex >= 0 && endIndex > startIndex) + { + var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim(); + return string.IsNullOrWhiteSpace(between) ? null : between; + } + + const string legacyPrefix = "Application answer draft:"; + var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); + if (legacyIndex >= 0) + { + var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim(); + return string.IsNullOrWhiteSpace(legacy) ? null : legacy; + } + + return null; + } + + public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage) + { + var subject = (lastMessage?.Subject ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(subject)) + { + return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) + ? subject + : $"Re: {subject}"; + } + + return $"Following up on {job.JobTitle} application"; + } + + public static List BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer) + { + var signals = new List(); + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}"); + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}"); + if (lastMessage is not null) + { + signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}"); + } + if (correspondenceContext?.Participants.Count > 0) + { + signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}"); + } + if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available"); + if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available"); + if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available"); + if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available"); + + if (correspondenceContext is not null) + { + foreach (var signal in correspondenceContext.Signals) + { + if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal); + } + } + + return signals.Take(6).ToList(); + } + + public static bool IsExtractableAttachmentExtension(string? extension) + { + return extension?.Trim().ToLowerInvariant() switch + { + ".pdf" => true, + ".docx" => true, + ".txt" => true, + ".md" => true, + ".png" => true, + ".jpg" => true, + ".jpeg" => true, + ".webp" => true, + _ => false, + }; + } + + public static List BuildFollowUpApproach(string status, List matchedTags, List missingTags) + { + var normalized = (status ?? string.Empty).Trim(); + var advice = new List(); + + switch (normalized) + { + case "Applied": + advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied."); + advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background."); + break; + case "Waiting": + advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific."); + advice.Add("Use one proof point that shows why you remain a strong fit."); + break; + case "Interview": + case "Interviewing": + advice.Add("Focus on momentum, appreciation, and readiness for the next step."); + advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible."); + break; + case "Offer": + advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing."); + advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress."); + break; + case "Rejected": + advice.Add("If appropriate, ask for feedback with a respectful and concise tone."); + advice.Add("Keep the door open for future opportunities instead of arguing the decision."); + break; + default: + advice.Add("Match the tone to the current stage and be specific about why you are following up now."); + advice.Add("Keep it concise, credible, and easy to respond to."); + break; + } + + if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}."); + if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly."); + + return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList(); + } + + public static IEnumerable SplitTags(string? s) + { + if (string.IsNullOrWhiteSpace(s)) yield break; + + var trimmed = s.Trim(); + + List? jsonTags = null; + if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) + { + try + { + jsonTags = JsonSerializer.Deserialize>(trimmed); + } + catch + { + jsonTags = null; + } + } + + if (jsonTags is not null) + { + foreach (var x in jsonTags) + { + var t = (x ?? string.Empty).Trim(); + if (t.Length == 0) continue; + yield return t; + } + yield break; + } + + foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) + { + var t = raw.Trim(); + if (t.Length == 0) continue; + yield return t; + } + } + + public static string NormalizeForComparison(string value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); + } + + public static string BuildSummarySource(JobApplication job) + { + // Prefer translated text for summaries and skill extraction so non-English + // postings become easier to understand while keeping the original text intact. + var parts = new[] + { + job.TranslatedDescription, + job.Description, + job.Notes + }; + + return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim())); + } + + public static string? NormalizeTags(string? raw) + { + var normalized = SplitTags(raw) + .Select(tag => tag.Trim()) + .Where(tag => tag.Length > 0) + .GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var first = group.First(); + return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant())); + }) + .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized); + } + + public static string? NormalizeUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + var value = url.Trim(); + return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value; + } + + public static string RemoveSavedApplicationAnswerDraft(string? notes) + { + var value = notes ?? string.Empty; + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); + var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); + if (startIndex >= 0 && endIndex > startIndex) + { + var before = value[..startIndex].Trim(); + var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim(); + return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim(); + } + + const string legacyPrefix = "Application answer draft:"; + var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); + if (legacyIndex >= 0) + { + return value[..legacyIndex].Trim(); + } + + return value.Trim(); + } + + public static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes)); + + public static bool IsInterviewStage(string status) => + status.Contains("Interview", StringComparison.OrdinalIgnoreCase); + + public static bool IsActiveWorkflowStatus(string status) + { + var normalized = (status ?? string.Empty).Trim(); + return normalized switch + { + "Applied" => true, + "Waiting" => true, + "Interview" => true, + "Interviewing" => true, + "Offer" => true, + _ => false, + }; + } + + public static WorkflowSignalDto BuildWorkflowSignal(JobApplication job, FollowUpDecision followUpDecision) + { + var hasTailoredCv = !string.IsNullOrWhiteSpace(job.TailoredCvText); + var hasSavedApplicationAnswerDraft = !string.IsNullOrWhiteSpace(ExtractSavedApplicationAnswerDraft(job.Notes)); + var hasInterviewPrepNotes = HasInterviewPrepNotes(job.Notes); + var needsInterviewPrep = IsInterviewStage(job.Status) && !hasInterviewPrepNotes; + var hasPackageGap = IsActiveWorkflowStatus(job.Status) && (!hasTailoredCv || !hasSavedApplicationAnswerDraft); + var needsFollowUpAction = followUpDecision.NeedsFollowUp || (!job.ResponseReceived && job.FollowUpAt is null); + + if (needsInterviewPrep) + { + return new WorkflowSignalDto( + ActionKey: "interview-prep", + Reason: "Interview stage reached but prep notes are still missing.", + WorkspaceTab: "interview-prep", + FollowMode: null, + NeedsAttention: true, + HasPackageGap: hasPackageGap, + NeedsInterviewPrep: true, + NeedsFollowUpAction: needsFollowUpAction, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + if (hasPackageGap) + { + var reason = !hasTailoredCv && !hasSavedApplicationAnswerDraft + ? "Tailored CV and saved application answers still need work." + : !hasTailoredCv + ? "Tailored CV missing for this role." + : "Saved application answers still need work."; + + return new WorkflowSignalDto( + ActionKey: "package-work", + Reason: reason, + WorkspaceTab: "tailored-cv", + FollowMode: null, + NeedsAttention: true, + HasPackageGap: true, + NeedsInterviewPrep: needsInterviewPrep, + NeedsFollowUpAction: needsFollowUpAction, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + if (needsFollowUpAction) + { + var reason = !string.IsNullOrWhiteSpace(followUpDecision.Reason) + ? followUpDecision.Reason! + : !job.ResponseReceived && job.FollowUpAt is null + ? "No response yet and no follow-up is scheduled." + : "Follow-up is due for this role."; + + return new WorkflowSignalDto( + ActionKey: "follow-up", + Reason: reason, + WorkspaceTab: "follow-up", + FollowMode: "waiting-update", + NeedsAttention: true, + HasPackageGap: hasPackageGap, + NeedsInterviewPrep: needsInterviewPrep, + NeedsFollowUpAction: true, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + return new WorkflowSignalDto( + ActionKey: "review-readiness", + Reason: "No urgent workflow gaps are blocking this job right now.", + WorkspaceTab: "readiness", + FollowMode: null, + NeedsAttention: false, + HasPackageGap: hasPackageGap, + NeedsInterviewPrep: needsInterviewPrep, + NeedsFollowUpAction: needsFollowUpAction, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + public static List BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal) + { + var reminders = new List(); + + if (workflowSignal.HasPackageGap) + { + reminders.Add(workflowSignal.HasTailoredCv + ? "Saved application answers are still missing from the package." + : workflowSignal.HasSavedApplicationAnswerDraft + ? "This role is active but still missing a tailored CV." + : "This role is active but still needs a tailored CV and saved application answers."); + } + + if (workflowSignal.NeedsInterviewPrep) + { + reminders.Add("Interview stage reached but prep notes are still missing."); + } + + if (workflowSignal.NeedsFollowUpAction) + { + reminders.Add(job.FollowUpAt is null + ? "No response yet and no follow-up is scheduled." + : workflowSignal.Reason); + } + + return reminders + .Where(reminder => !string.IsNullOrWhiteSpace(reminder)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +}