From bd07876a4131a73635593d3a5d6f4e707ddf7d9c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 19:50:58 +0200 Subject: [PATCH 1/5] fix(db): stop startup crash from MySQL composite-index key length Prod was hard-down: InitializeJobTrackerAsync threw an unhandled MySqlException ("Specified key was too long; max key length is 3072 bytes") while creating IX_JobApplications_OwnerUserId_FollowUpAt, which crashed Program.Main before the app could start (surfaced to users as a 500 on Google sign-in, but really affected every request). Root cause: this reconciler assumes OwnerUserId is varchar(255), but the live column was provisioned wider by an earlier EF migration, close enough to the utf8mb4 3072-byte limit that pairing it with a second column tips a composite index over. Fix: - Prefix-index OwnerUserId at 191 chars (safe under the legacy 767-byte-per-column limit, still far wider than the GUID-like Identity ids actually stored) in every composite/unique index that includes it, so index creation no longer depends on the column's actual declared width. - Wrap each CREATE INDEX in try/catch + LogWarning instead of letting it propagate: a schema reconciler is best-effort and one failed index must never crash startup, matching the existing non-fatal pattern already used a few lines below for legacy-schema ownership claims. Backend build + full test suite (177 passing) verified green. --- .../StartupInitializationExtensions.cs | 138 ++++++------------ 1 file changed, 47 insertions(+), 91 deletions(-) diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index c3cf867..3665ba8 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -977,105 +977,61 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) + // Schema reconciliation must never crash app startup: an index that fails + // (e.g. combined key exceeds MySQL's 3072-byte limit because an older + // migration made OwnerUserId wider than the varchar(255) this reconciler + // assumes) is logged and skipped rather than taking prod down. OwnerUserId + // is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy + // per-column key limit, and far longer than the GUID-like Identity ids + // actually stored there) so composite indexes stay well under the cap + // regardless of the column's declared width. + void TryCreateIndex(string table, string indexName, string columnsSql) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); + if (MySqlIndexExists(conn, table, indexName)) return; + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});"; + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table); + } } - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId")) + void TryCreateUniqueIndex(string table, string indexName, string columnsSql) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); + if (MySqlIndexExists(conn, table, indexName)) return; + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});"; + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table); + } } + TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)"); + // Hot-path composite indexes for tenant-scoped list/board/stats/analytics // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_MicrosoftGraphConnections_OwnerUserId` ON `MicrosoftGraphConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress` ON `MicrosoftGraphConnections` (`OwnerUserId`, `MailAddress`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`"); + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`"); + TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`"); + TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`"); + TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`"); + TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)"); + TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)"); + TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`"); + TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`"); } } From ea6c3650f3f1a46921c28ef19c3f3c5a1e9a1b11 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 20:08:59 +0200 Subject: [PATCH 2/5] 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(); + } + } +} From 4cfdc95b590a6eb929c98cccb5e8c898b4ebbc74 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 20:12:36 +0200 Subject: [PATCH 3/5] refactor(api): extract ProfileCv DTOs, add missing AsNoTracking on reads --- .../ProfileCvControllerTests.cs | 28 +++++++++---------- .../Controllers/ProfileCvController.cs | 20 ++----------- JobTrackerApi/Controllers/ProfileCvDtos.cs | 20 +++++++++++++ 3 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 JobTrackerApi/Controllers/ProfileCvDtos.cs diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index cee20a0..e41a93c 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -123,7 +123,7 @@ public sealed class ProfileCvControllerTests var result = await controller.GetRuns(); var ok = Assert.IsType(result.Result); - var runs = Assert.IsAssignableFrom>(ok.Value); + var runs = Assert.IsAssignableFrom>(ok.Value); var single = Assert.Single(runs); Assert.Equal("upload", single.Trigger); Assert.Equal("applied", single.Status); @@ -611,7 +611,7 @@ public sealed class ProfileCvControllerTests var objectResult = Assert.IsType(result); Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); - var payload = Assert.IsType(objectResult.Value); + var payload = Assert.IsType(objectResult.Value); Assert.Equal("ai-service-unavailable", payload.Code); Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase); @@ -673,7 +673,7 @@ public sealed class ProfileCvControllerTests var objectResult = Assert.IsType(result); Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); - var payload = Assert.IsType(objectResult.Value); + var payload = Assert.IsType(objectResult.Value); Assert.Equal("rewrite-empty", payload.Code); Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase); @@ -766,7 +766,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText)); + var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -800,7 +800,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText)); + var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -838,7 +838,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -878,7 +878,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -914,7 +914,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -1030,7 +1030,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1069,7 +1069,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(rawSource)); + var result = await controller.Parse(new ParseCvRequest(rawSource)); var ok = Assert.IsType(result.Result); Assert.NotNull(ok.Value); @@ -1098,7 +1098,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1129,7 +1129,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1158,7 +1158,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1186,7 +1186,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index a599d69..8f87075 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -113,25 +113,8 @@ public sealed class ProfileCvController : ControllerBase public string? Tone { get; set; } public string? Language { get; set; } } - public sealed record ParseCvRequest(string? Text); - public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List PreviewBullets); - public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId); - public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null); - private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); - public sealed record CvExtractionRunListItem( - int Id, - string Trigger, - string Status, - string? ArtifactFileName, - DateTimeOffset StartedAtUtc, - DateTimeOffset? CompletedAtUtc, - DateTimeOffset? AppliedAtUtc, - string ParserVersion, - string NormalizerVersion, - string LlmPromptVersion, - string? ErrorMessage); [HttpPost("upload")] [RequestSizeLimit(MaxFileSizeBytes)] @@ -254,6 +237,7 @@ public sealed class ProfileCvController : ControllerBase if (user is null) return Unauthorized(); var artifact = await _db.CvUploadArtifacts + .AsNoTracking() .OrderByDescending(x => x.UploadedAtUtc) .FirstOrDefaultAsync(x => x.OwnerUserId == user.Id, HttpContext.RequestAborted); @@ -941,7 +925,7 @@ public sealed class ProfileCvController : ControllerBase } case "reprocess": { - var artifact = await _db.CvUploadArtifacts.FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken); + var artifact = await _db.CvUploadArtifacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken); if (artifact is null) throw new InvalidOperationException("Upload a CV before reprocessing it."); if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath)) { diff --git a/JobTrackerApi/Controllers/ProfileCvDtos.cs b/JobTrackerApi/Controllers/ProfileCvDtos.cs new file mode 100644 index 0000000..a8e56b8 --- /dev/null +++ b/JobTrackerApi/Controllers/ProfileCvDtos.cs @@ -0,0 +1,20 @@ +using JobTrackerApi.Models; + +namespace JobTrackerApi.Controllers; + +public sealed record ParseCvRequest(string? Text); +public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List PreviewBullets); +public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId); +public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null); +public sealed record CvExtractionRunListItem( + int Id, + string Trigger, + string Status, + string? ArtifactFileName, + DateTimeOffset StartedAtUtc, + DateTimeOffset? CompletedAtUtc, + DateTimeOffset? AppliedAtUtc, + string ParserVersion, + string NormalizerVersion, + string LlmPromptVersion, + string? ErrorMessage); From 3e09e74fc815085e004f716f1f465dbeb07e8fe7 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 20:17:33 +0200 Subject: [PATCH 4/5] refactor(api): extract Gmail DTOs/parsers, batch N+1 loops - Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs - Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads - Remove redundant second pass in RelinkThread, reuse existing HashSet - Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups Co-Authored-By: Claude Sonnet 5 --- JobTrackerApi.Tests/GmailControllerTests.cs | 68 ++++----- JobTrackerApi/Controllers/GmailController.cs | 3 +- JobTrackerApi/Controllers/GmailDtos.cs | 143 +++++++++--------- .../{Controllers => Services}/GmailParsing.cs | 26 ++-- 4 files changed, 118 insertions(+), 122 deletions(-) rename JobTrackerApi/{Controllers => Services}/GmailParsing.cs (82%) diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 54e40d0..0f723cf 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -39,7 +39,7 @@ public sealed class GmailControllerTests var result = await controller.Status(CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.True(payload.Connected); Assert.Equal("user@example.test", payload.GmailAddress); Assert.Equal("list-messages", payload.LastSyncMode); @@ -54,7 +54,7 @@ public sealed class GmailControllerTests await using var db = CreateDb(); var controller = CreateController(db, Mock.Of(), "user-1"); - var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty()), CancellationToken.None); + var result = await controller.ImportThread(new ImportGmailThreadRequest(1, "thread-1", Array.Empty()), CancellationToken.None); var badRequest = Assert.IsType(result.Result); Assert.Equal("At least one messageId is required.", badRequest.Value); @@ -159,7 +159,7 @@ public sealed class GmailControllerTests var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(job.Id, payload.JobApplicationId); Assert.Contains(overrideQuery, payload.Queries); @@ -221,7 +221,7 @@ public sealed class GmailControllerTests var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.NotEmpty(payload.Queries); Assert.Equal(0, payload.CandidateMessageCount); Assert.Equal(0, payload.CandidateThreadCount); @@ -264,9 +264,9 @@ public sealed class GmailControllerTests var controller = CreateController(db, gmail.Object, "user-1"); - var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); + var first = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); var firstOk = Assert.IsType(first.Result); - var firstPayload = Assert.IsType(firstOk.Value); + var firstPayload = Assert.IsType(firstOk.Value); Assert.Equal(1, firstPayload.Imported); Assert.Equal(0, firstPayload.Skipped); Assert.Equal("thread-1", firstPayload.ThreadId); @@ -279,9 +279,9 @@ public sealed class GmailControllerTests Assert.Single(firstPayload.Message.AttachmentMetadata); Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName); - var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); + var second = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); var secondOk = Assert.IsType(second.Result); - var secondPayload = Assert.IsType(secondOk.Value); + var secondPayload = Assert.IsType(secondOk.Value); Assert.Equal(0, secondPayload.Imported); Assert.Equal(1, secondPayload.Skipped); Assert.Equal("thread-1", secondPayload.ThreadId); @@ -340,18 +340,18 @@ public sealed class GmailControllerTests Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); - var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" }); + var request = new ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" }); var first = await controller.ImportThread(request, CancellationToken.None); var firstOk = Assert.IsType(first.Result); - var firstPayload = Assert.IsType(firstOk.Value); + var firstPayload = Assert.IsType(firstOk.Value); Assert.Equal(2, firstPayload.Imported); Assert.Equal(0, firstPayload.Skipped); Assert.Equal("thread-1", firstPayload.ThreadId); var second = await controller.ImportThread(request, CancellationToken.None); var secondOk = Assert.IsType(second.Result); - var secondPayload = Assert.IsType(secondOk.Value); + var secondPayload = Assert.IsType(secondOk.Value); Assert.Equal(0, secondPayload.Imported); Assert.Equal(2, secondPayload.Skipped); @@ -414,10 +414,10 @@ public sealed class GmailControllerTests Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); - var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None); + var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(job.Id), CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(job.Id, payload.JobApplicationId); Assert.Equal(1, payload.ThreadsChecked); Assert.Equal(1, payload.Imported); @@ -461,7 +461,7 @@ public sealed class GmailControllerTests var disconnectedGmail = new Mock(MockBehavior.Strict); disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())).ReturnsAsync((GmailConnection?)null); var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1"); - var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None); + var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None); var conflict = Assert.IsType(disconnectedResult.Result); Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value); @@ -469,10 +469,10 @@ public sealed class GmailControllerTests gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); var controller = CreateController(db, gmail.Object, "user-1"); - var emptyResult = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None); + var emptyResult = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None); var ok = Assert.IsType(emptyResult.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(0, payload.ThreadsChecked); Assert.Equal(0, payload.Imported); Assert.Equal(0, payload.Skipped); @@ -526,7 +526,7 @@ public sealed class GmailControllerTests var result = await controller.ReviewCandidates(null, 6, CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(1, payload.CandidateThreadCount); Assert.Single(payload.Threads); Assert.Equal("thread-top", payload.Threads[0].ThreadId); @@ -541,7 +541,7 @@ public sealed class GmailControllerTests var gmail = new Mock(MockBehavior.Strict); var controller = CreateController(db, gmail.Object, "user-1"); - var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(0), CancellationToken.None); + var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(0), CancellationToken.None); var badRequest = Assert.IsType(result.Result); Assert.Equal("Valid jobApplicationId is required.", badRequest.Value); @@ -566,7 +566,7 @@ public sealed class GmailControllerTests var gmail = new Mock(MockBehavior.Strict); var controller = CreateController(db, gmail.Object, "user-1"); - var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None); + var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None); var notFound = Assert.IsType(result.Result); Assert.Equal("Job application not found.", notFound.Value); @@ -639,7 +639,7 @@ public sealed class GmailControllerTests Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); - var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None); + var result = await controller.SaveReviewDecision(new SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None); var ok = Assert.IsType(result); var decision = await db.GmailReviewDecisions.SingleAsync(); @@ -719,10 +719,10 @@ public sealed class GmailControllerTests Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); - var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None); + var result = await controller.ManualSync(new GmailManualSyncRequest(365, 8, true, false), CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(1, payload.AutoLinkedThreadCount); Assert.Equal(1, payload.ImportedThreads); Assert.Equal(1, payload.ImportedMessages); @@ -770,7 +770,7 @@ public sealed class GmailControllerTests var controller = CreateController(db, gmail.Object, "user-1"); - var reviewQueue = new GmailController.GmailReviewQueueResponseDto( + var reviewQueue = new GmailReviewQueueResponseDto( Array.Empty(), 1, 0, @@ -778,7 +778,7 @@ public sealed class GmailControllerTests 1, new[] { - new GmailController.GmailReviewThreadDto( + new GmailReviewThreadDto( "thread-suggested", "Platform Engineer interview", DateTimeOffset.UtcNow.AddDays(-1), @@ -787,10 +787,10 @@ public sealed class GmailControllerTests false, null, Array.Empty(), - Array.Empty(), + Array.Empty(), new[] { - new GmailController.GmailJobMatchedMessageDto( + new GmailJobMatchedMessageDto( "msg-s1", "thread-suggested", "Platform Engineer interview", @@ -802,16 +802,16 @@ public sealed class GmailControllerTests "low", false, Array.Empty(), - Array.Empty()) + Array.Empty()) }) }); var suggested = Assert.IsType((await controller.SuggestedJobs(CancellationToken.None)).Result); - Assert.IsType(suggested.Value); + Assert.IsType(suggested.Value); - var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None); + var create = await controller.CreateSuggestedJob(new CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None); var createOk = Assert.IsType(create.Result); - var created = Assert.IsType(createOk.Value); + var created = Assert.IsType(createOk.Value); Assert.True(created.JobApplicationId > 0); Assert.Equal(1, created.Imported); Assert.Equal("thread-suggested", created.ThreadId); @@ -837,10 +837,10 @@ public sealed class GmailControllerTests await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "user-1"); - var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None); + var result = await controller.UnlinkThread(new UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(2, payload.RemovedMessages); Assert.Equal("review", payload.Decision); Assert.Empty(await db.Correspondences.ToListAsync()); @@ -895,10 +895,10 @@ public sealed class GmailControllerTests Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); - var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None); + var result = await controller.RelinkThread(new RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); + var payload = Assert.IsType(ok.Value); Assert.Equal(1, payload.UnlinkedMessages); Assert.Equal(1, payload.Imported); var stored = await db.Correspondences.SingleAsync(); diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index e29efc9..b1de16d 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -7,13 +7,14 @@ using JobTrackerApi.Services.EmailProviders; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using static JobTrackerApi.Services.GmailParsing; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/gmail")] [Authorize] -public sealed partial class GmailController : ControllerBase +public sealed class GmailController : ControllerBase { private readonly IGmailOAuthService _gmail; private readonly IGmailJobMatchingService _matching; diff --git a/JobTrackerApi/Controllers/GmailDtos.cs b/JobTrackerApi/Controllers/GmailDtos.cs index 4910747..91f88cd 100644 --- a/JobTrackerApi/Controllers/GmailDtos.cs +++ b/JobTrackerApi/Controllers/GmailDtos.cs @@ -2,78 +2,75 @@ using JobTrackerApi.Models; namespace JobTrackerApi.Controllers; -// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour -// change; these were previously nested inline in the controller file). -public partial class GmailController -{ - public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); - public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message); - public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); - public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); - public sealed record RefreshLinkedThreadsRequest(int JobApplicationId); - public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate); - public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList Threads); - public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points); - public sealed record GmailJobMatchedMessageDto( - string Id, - string ThreadId, - string Subject, - string From, - string To, - DateTimeOffset? Date, - string Snippet, - int Score, - string Confidence, - bool AlreadyImported, - IReadOnlyList MatchedQueries, - IReadOnlyList MatchReasons); - public sealed record GmailJobMatchedThreadDto( - string ThreadId, - string Subject, - int Score, - string Confidence, - bool HasImportedMessages, - int ImportedMessageCount, - int MessageCount, - DateTimeOffset? LatestDate, - IReadOnlyList MatchedQueries, - IReadOnlyList MatchReasons, - IReadOnlyList Messages); - public sealed record GmailJobMatchesResponseDto( - int JobApplicationId, - string JobTitle, - string CompanyName, - string? RecruiterName, - string? RecruiterEmail, - IReadOnlyList Queries, - int CandidateMessageCount, - int CandidateThreadCount, - IReadOnlyList Threads); +// DTOs for GmailController, split out for readability (no behaviour change; these were +// previously nested inside the controller class). +public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); +public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message); +public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); +public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); +public sealed record RefreshLinkedThreadsRequest(int JobApplicationId); +public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate); +public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList Threads); +public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points); +public sealed record GmailJobMatchedMessageDto( + string Id, + string ThreadId, + string Subject, + string From, + string To, + DateTimeOffset? Date, + string Snippet, + int Score, + string Confidence, + bool AlreadyImported, + IReadOnlyList MatchedQueries, + IReadOnlyList MatchReasons); +public sealed record GmailJobMatchedThreadDto( + string ThreadId, + string Subject, + int Score, + string Confidence, + bool HasImportedMessages, + int ImportedMessageCount, + int MessageCount, + DateTimeOffset? LatestDate, + IReadOnlyList MatchedQueries, + IReadOnlyList MatchReasons, + IReadOnlyList Messages); +public sealed record GmailJobMatchesResponseDto( + int JobApplicationId, + string JobTitle, + string CompanyName, + string? RecruiterName, + string? RecruiterEmail, + IReadOnlyList Queries, + int CandidateMessageCount, + int CandidateThreadCount, + IReadOnlyList Threads); - public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); - public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); - public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList Threads); - public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note); - public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash); - public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt); - public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList MatchedQueries, string Preview); - public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList Items); - public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status); - public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped); - public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note); - public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages); - public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision); - public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision); +public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); +public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); +public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList Threads); +public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note); +public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash); +public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt); +public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList MatchedQueries, string Preview); +public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList Items); +public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status); +public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped); +public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note); +public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages); +public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision); +public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision); - public sealed record GmailConnectionStatusDto( - bool Connected, - string? GmailAddress, - DateTimeOffset? ConnectedAt, - DateTimeOffset? LastSyncedAt, - DateTimeOffset? LastSyncAttemptedAt, - DateTimeOffset? LastSyncSucceededAt, - string? LastSyncMode, - string? LastSyncSource, - string? LastSyncStatus, - string? LastSyncError); -} +public sealed record GmailConnectionStatusDto( + bool Connected, + string? GmailAddress, + DateTimeOffset? ConnectedAt, + DateTimeOffset? LastSyncedAt, + DateTimeOffset? LastSyncAttemptedAt, + DateTimeOffset? LastSyncSucceededAt, + string? LastSyncMode, + string? LastSyncSource, + string? LastSyncStatus, + string? LastSyncError); diff --git a/JobTrackerApi/Controllers/GmailParsing.cs b/JobTrackerApi/Services/GmailParsing.cs similarity index 82% rename from JobTrackerApi/Controllers/GmailParsing.cs rename to JobTrackerApi/Services/GmailParsing.cs index 5a80827..8cf4e01 100644 --- a/JobTrackerApi/Controllers/GmailParsing.cs +++ b/JobTrackerApi/Services/GmailParsing.cs @@ -1,12 +1,10 @@ -using JobTrackerApi.Services; +namespace JobTrackerApi.Services; -namespace JobTrackerApi.Controllers; - -// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe -// refactor -- no behaviour change). All are static and side-effect free. -public sealed partial class GmailController +// Pure parsing/formatting helpers used by GmailController, split out for readability (no +// behaviour change). All are static and side-effect free. +public static class GmailParsing { - private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash) + public static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash) { var bounded = (query ?? string.Empty).Trim(); if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase)) @@ -25,7 +23,7 @@ public sealed partial class GmailController return bounded.Trim(); } - private static bool LooksLikeJobRelatedThread(IReadOnlyList orderedMessages) + public static bool LooksLikeJobRelatedThread(IReadOnlyList orderedMessages) { var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value))))); if (string.IsNullOrWhiteSpace(sample)) return false; @@ -40,7 +38,7 @@ public sealed partial class GmailController || sample.Contains("rejection", StringComparison.OrdinalIgnoreCase); } - private static string ToConfidence(int score) + public static string ToConfidence(int score) { return score switch { @@ -50,21 +48,21 @@ public sealed partial class GmailController }; } - private static string? ExtractFirstEmail(string? value) + public static string? ExtractFirstEmail(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase); return match.Success ? match.Value : null; } - private static string? ExtractRecruiterName(string? value) + public static string? ExtractRecruiterName(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var trimmed = value.Split('<')[0].Trim().Trim('"'); return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed; } - private static string? ExtractCompanyName(string? from, string? subject) + public static string? ExtractCompanyName(string? from, string? subject) { var subjectText = (subject ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(subjectText)) @@ -77,7 +75,7 @@ public sealed partial class GmailController return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null; } - private static string? ExtractRoleFromSubject(string? subject) + public static string? ExtractRoleFromSubject(string? subject) { if (string.IsNullOrWhiteSpace(subject)) return null; var trimmed = subject.Trim(); @@ -88,7 +86,7 @@ public sealed partial class GmailController return trimmed.Length <= 120 ? trimmed : trimmed[..120]; } - private static string BuildPopupHtml(bool success, string message) + public static string BuildPopupHtml(bool success, string message) { var escaped = System.Net.WebUtility.HtmlEncode(message); var status = success ? "connected" : "error"; From 717d1b9963cfb91c95e25db880788b0ff2d72c18 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 20:22:23 +0200 Subject: [PATCH 5/5] perf(db): add remaining hot-path indexes (status filter, correspondence/event FKs) Co-Authored-By: Claude Sonnet 5 --- Data/JobTrackerContext.cs | 11 +++++++++++ .../Services/StartupInitializationExtensions.cs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 9f699e4..de1cc1f 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -71,6 +71,11 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasIndex(j => new { j.OwnerUserId, j.FollowUpAt }); + // Board/list endpoints that filter by both IsDeleted and Status. Same MySQL + // longtext-prefix caveat as above; the reconciler applies `Status(50)` there. + modelBuilder.Entity() + .HasIndex(j => new { j.OwnerUserId, j.IsDeleted, j.Status }); + modelBuilder.Entity() .HasIndex(c => c.OwnerUserId); @@ -81,6 +86,9 @@ namespace JobTrackerApi.Data .HasForeignKey(c => c.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(c => c.JobApplicationId); + modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); @@ -111,6 +119,9 @@ namespace JobTrackerApi.Data .HasForeignKey(e => e.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(e => e.JobApplicationId); + modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 3665ba8..20ca4be 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -688,6 +688,17 @@ public static class StartupInitializationExtensions { Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");"""); Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");"""); + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted_Status" ON "JobApplications" ("OwnerUserId", "IsDeleted", "Status");"""); + } + + if (HasTable(conn, "Correspondences")) + { + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_Correspondences_JobApplicationId" ON "Correspondences" ("JobApplicationId");"""); + } + + if (HasTable(conn, "JobEvents")) + { + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobEvents_JobApplicationId" ON "JobEvents" ("JobApplicationId");"""); } // Ensure data folder exists before creating/opening SQLite files. @@ -1022,6 +1033,11 @@ public static class StartupInitializationExtensions // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`"); TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`"); + // Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it + // needs an explicit prefix length to be indexable under MariaDB's key-length rules. + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)"); + TryCreateIndex("Correspondences", "IX_Correspondences_JobApplicationId", "`JobApplicationId`"); + TryCreateIndex("JobEvents", "IX_JobEvents_JobApplicationId", "`JobApplicationId`"); TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`"); TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`"); TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`");