From eac34705e335f34e421fc22b5c53b32a3f4e14c8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 17 Jul 2026 17:05:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=200=20foundation=20=E2=80=94=20Jo?= =?UTF-8?q?b=20entity,=20expanded=20pipeline,=20AI=20service=20lockdown,?= =?UTF-8?q?=20DateApplied=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 + .gitignore | 6 + Data/JobTrackerContext.cs | 28 + .../JobApplicationsAppliedDateHistoryTests.cs | 133 ++ JobTrackerApi.Tests/JobPipelineTests.cs | 170 ++ .../RulesEngineProspectTests.cs | 82 + JobTrackerApi/Controllers/ExportController.cs | 2 +- .../Controllers/JobApplicationDtos.cs | 16 +- .../Controllers/JobApplicationsController.cs | 48 +- ..._AddJobEntityAndProspectStages.Designer.cs | 1387 +++++++++++++++++ ...717071417_AddJobEntityAndProspectStages.cs | 162 ++ .../JobTrackerContextModelSnapshot.cs | 249 ++- JobTrackerApi/Program.cs | 7 + JobTrackerApi/Services/AnalyticsService.cs | 17 +- .../Services/FollowUpReminderHostedService.cs | 4 +- JobTrackerApi/Services/JobPipeline.cs | 116 +- JobTrackerApi/Services/RulesEngine.cs | 13 +- Models/Job.cs | 57 + Models/JobApplication.cs | 29 +- docker-compose.yml | 42 +- .../src/components/EditJobDialog.tsx | 2 +- .../src/components/ImportExportJobs.tsx | 4 +- .../src/components/JobDetailsDialog.tsx | 4 +- job-tracker-ui/src/components/JobFlowBar.tsx | 14 +- job-tracker-ui/src/components/JobTable.tsx | 2 +- job-tracker-ui/src/components/KanbanBoard.tsx | 121 +- job-tracker-ui/src/i18n/translations.ts | 20 +- .../src/kanban-grouped-board.test.tsx | 121 ++ job-tracker-ui/src/pipeline.test.ts | 76 +- job-tracker-ui/src/pipeline.ts | 88 +- job-tracker-ui/src/types.ts | 10 +- .../src/views/CareerWorkspacePage.tsx | 20 +- job-tracker-ui/src/views/ProfilePage.tsx | 10 +- .../src/workflow-trust-signals.test.tsx | 1 + tools/summarizer/app.py | 31 +- tools/summarizer/tests/test_app.py | 61 +- 36 files changed, 3060 insertions(+), 96 deletions(-) create mode 100644 JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs create mode 100644 JobTrackerApi.Tests/RulesEngineProspectTests.cs create mode 100644 JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.Designer.cs create mode 100644 JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.cs create mode 100644 Models/Job.cs create mode 100644 job-tracker-ui/src/kanban-grouped-board.test.tsx diff --git a/.env.example b/.env.example index d07ead5..2ee55f6 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ MICROSOFT_TENANT_ID= # Optional. If omitted, the backend uses https:///api/microsoft-graph/oauth/callback MICROSOFT_REDIRECT_URI= AI_SERVICE_BASE_URL=http://ai-service:8001 +# REQUIRED. Shared secret the backend sends to ai-service on every call except /health. +# The stack refuses to start without it. Generate with: openssl rand -hex 32 +AI_SERVICE_TOKEN= # Optional: enables hybrid CV block classification in the local AI service. OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_MODEL=qwen2.5:7b diff --git a/.gitignore b/.gitignore index f6bb9c6..54c58d1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,15 @@ JobTrackerApi/CvBenchmarks/ # Local app data *.db *.db-* +*.db.preview-bak Attachments/ website_details.md +# Local-only files (dev overrides, scratch, archives) +docker-compose.override.yml +docs.7z +todo.md + # Private local test files vendor/ target/ diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 995f59f..661d931 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -14,6 +14,7 @@ namespace JobTrackerApi.Data } public DbSet Companies => Set(); + public DbSet Jobs => Set(); public DbSet JobApplications => Set(); public DbSet Correspondences => Set(); public DbSet GmailConnections => Set(); @@ -42,6 +43,33 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId); + // Job (the opportunity) is tenant-owned like everything else: same deny-on-null filter, + // so a null CurrentUserId returns nothing rather than every tenant's rows. + modelBuilder.Entity() + .HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId); + + // WithMany() with no inverse navigation: Company.Jobs is already the JobApplication + // collection (a legacy name predating this split), so Job hangs off Company without + // claiming it. Restrict rather than Cascade — deleting a company should not silently + // destroy opportunity records that applications may still reference. + modelBuilder.Entity() + .HasOne(j => j.Company) + .WithMany() + .HasForeignKey(j => j.CompanyId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(j => j.OwnerUserId); + + // SetNull, not Cascade: an application must survive its Job row being removed, since + // JobApplication still carries its own copy of the opportunity columns during the + // Phase 0 -> Phase 1 transition. + modelBuilder.Entity() + .HasOne(j => j.Job) + .WithMany(o => o.Applications) + .HasForeignKey(j => j.JobId) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() .HasKey(x => x.OwnerUserId); diff --git a/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs b/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs new file mode 100644 index 0000000..32d431e --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs @@ -0,0 +1,133 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +/// +/// Moving a job backwards into a pre-application stage clears DateApplied so analytics stay +/// accurate. These tests guard the other half of that bargain: the application activity must be +/// preserved as history rather than destroyed. +/// +public sealed class JobApplicationsAppliedDateHistoryTests +{ + private static readonly DateTime AppliedOn = new(2026, 3, 1, 9, 30, 0, DateTimeKind.Utc); + + [Fact] + public async Task Moving_back_to_a_prospect_stage_clears_the_date_but_records_it_as_history() + { + await using var db = CreateDb(); + var job = await SeedAppliedJob(db); + var controller = CreateController(db, "user-1"); + + await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Saved"), CancellationToken.None); + + var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); + Assert.Equal("Saved", saved.Status); + Assert.Null(saved.DateApplied); + + var cleared = await db.JobEvents.SingleAsync(e => e.Type == JobPipeline.AppliedDateClearedEvent); + // Round-tripped, so the original date is recoverable exactly — not just described in prose. + Assert.Equal(AppliedOn, DateTime.Parse(cleared.OldValue!, null, System.Globalization.DateTimeStyles.RoundtripKind)); + + // The status transition itself is still recorded separately. + Assert.Contains(await db.JobEvents.ToListAsync(), e => e.Type == "StatusChanged" && e.NewValue == "Saved"); + } + + [Fact] + public async Task Re_applying_after_a_backwards_move_stamps_a_fresh_date_and_leaves_history_intact() + { + await using var db = CreateDb(); + var job = await SeedAppliedJob(db); + var controller = CreateController(db, "user-1"); + + await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Saved"), CancellationToken.None); + await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Applied"), CancellationToken.None); + + var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); + Assert.Equal("Applied", saved.Status); + Assert.NotNull(saved.DateApplied); + Assert.NotEqual(AppliedOn, saved.DateApplied); + + // The first application's date survives the round trip in history. + var cleared = await db.JobEvents.SingleAsync(e => e.Type == JobPipeline.AppliedDateClearedEvent); + Assert.Equal(AppliedOn, DateTime.Parse(cleared.OldValue!, null, System.Globalization.DateTimeStyles.RoundtripKind)); + } + + [Fact] + public async Task Moving_between_post_application_stages_records_no_cleared_date_event() + { + await using var db = CreateDb(); + var job = await SeedAppliedJob(db); + var controller = CreateController(db, "user-1"); + + await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Interview"), CancellationToken.None); + + var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); + Assert.Equal(AppliedOn, saved.DateApplied); + Assert.DoesNotContain(await db.JobEvents.ToListAsync(), e => e.Type == JobPipeline.AppliedDateClearedEvent); + } + + [Fact] + public async Task Withdrawing_keeps_the_applied_date() + { + await using var db = CreateDb(); + var job = await SeedAppliedJob(db); + var controller = CreateController(db, "user-1"); + + await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Withdrawn"), CancellationToken.None); + + // Withdrawn is a closed stage, not a pre-application one: you did apply, then pulled out. + var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); + Assert.Equal("Withdrawn", saved.Status); + Assert.Equal(AppliedOn, saved.DateApplied); + } + + private static async Task SeedAppliedJob(JobTrackerContext db) + { + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Status = "Applied", + DateApplied = AppliedOn, + SavedAt = AppliedOn.AddDays(-2), + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static JobApplicationsController CreateController(JobTrackerContext db, string userId) + { + var controller = new JobApplicationsController(db, Mock.Of(), Mock.Of(), TestHostFactory.CreateUserManager().Object, NullLogger.Instance); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } + + private static JobTrackerContext CreateDb() => TestHostFactory.CreateInMemoryDb(); +} diff --git a/JobTrackerApi.Tests/JobPipelineTests.cs b/JobTrackerApi.Tests/JobPipelineTests.cs index 55b1a19..86cefff 100644 --- a/JobTrackerApi.Tests/JobPipelineTests.cs +++ b/JobTrackerApi.Tests/JobPipelineTests.cs @@ -1,3 +1,5 @@ +using System; +using JobTrackerApi.Models; using JobTrackerApi.Services; using Xunit; @@ -51,4 +53,172 @@ public sealed class JobPipelineTests Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical Assert.False(JobPipeline.IsCanonical("Whatever")); } + + // --- Pre-application (Prospect) stages ------------------------------------------------ + + [Theory] + [InlineData("bookmarked", "Saved")] + [InlineData("to apply", "Saved")] + [InlineData("shortlisted", "Interested")] + [InlineData("drafting", "Preparing")] + [InlineData("in preparation", "Preparing")] + public void Normalize_canonicalizes_prospect_synonyms(string input, string expected) + => Assert.Equal(expected, JobPipeline.Normalize(input)); + + [Theory] + [InlineData("Saved")] + [InlineData("Interested")] + [InlineData("Preparing")] + [InlineData("bookmarked")] + public void IsProspect_true_for_pre_application_stages(string status) + => Assert.True(JobPipeline.IsProspect(status)); + + [Theory] + [InlineData("Applied")] + [InlineData("Waiting")] + [InlineData("Interview")] + [InlineData("Offer")] + [InlineData("Rejected")] + [InlineData("Ghosted")] + public void IsProspect_false_for_post_application_stages(string status) + => Assert.False(JobPipeline.IsProspect(status)); + + [Fact] + public void IsProspect_false_for_custom_status() + // Custom statuses predate the split and have always counted as applied. Treating them as + // prospects would silently drop them out of existing users' analytics. + => Assert.False(JobPipeline.IsProspect("Take-home assignment")); + + [Fact] + public void Prospect_stages_sort_before_applied() + { + Assert.True(JobPipeline.OrderOf("Saved") < JobPipeline.OrderOf("Interested")); + Assert.True(JobPipeline.OrderOf("Interested") < JobPipeline.OrderOf("Preparing")); + Assert.True(JobPipeline.OrderOf("Preparing") < JobPipeline.OrderOf("Applied")); + } + + // --- SyncAppliedDate: DateApplied set <=> the job has left the Prospect stages ----------- + + [Fact] + public void SyncAppliedDate_clears_the_applied_date_for_a_prospect() + { + var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + var job = new JobApplication { Status = "Saved", DateApplied = now.AddDays(-5) }; + + JobPipeline.SyncAppliedDate(job, now); + + Assert.Null(job.DateApplied); + } + + [Fact] + public void SyncAppliedDate_stamps_when_a_prospect_becomes_applied() + { + var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + var job = new JobApplication { Status = "Preparing", DateApplied = null }; + + job.Status = "Applied"; + JobPipeline.SyncAppliedDate(job, now); + + Assert.Equal(now, job.DateApplied); + } + + [Fact] + public void SyncAppliedDate_preserves_an_existing_applied_date() + { + var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + var applied = now.AddDays(-30); + var job = new JobApplication { Status = "Interview", DateApplied = applied }; + + JobPipeline.SyncAppliedDate(job, now); + + Assert.Equal(applied, job.DateApplied); + } + + [Fact] + public void DaysSince_is_null_without_an_applied_date() + => Assert.Null(new JobApplication { Status = "Saved", DateApplied = null }.DaysSince); + + [Fact] + public void SyncAppliedDate_returns_the_cleared_date_so_callers_can_record_it() + { + var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + var applied = now.AddDays(-5); + var job = new JobApplication { Status = "Saved", DateApplied = applied }; + + var cleared = JobPipeline.SyncAppliedDate(job, now); + + // The controller persists this as an AppliedDateCleared JobEvent, so the application + // activity survives a backwards move. + Assert.Equal(applied, cleared); + Assert.Null(job.DateApplied); + } + + [Fact] + public void SyncAppliedDate_returns_null_when_nothing_was_cleared() + { + var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + + Assert.Null(JobPipeline.SyncAppliedDate(new JobApplication { Status = "Applied", DateApplied = now.AddDays(-1) }, now)); + Assert.Null(JobPipeline.SyncAppliedDate(new JobApplication { Status = "Saved", DateApplied = null }, now)); + } + + // --- Withdrawn + board grouping --------------------------------------------------------- + + [Theory] + [InlineData("withdrew", "Withdrawn")] + [InlineData("cancelled", "Withdrawn")] + [InlineData("canceled", "Withdrawn")] + public void Normalize_canonicalizes_withdrawn_synonyms(string input, string expected) + => Assert.Equal(expected, JobPipeline.Normalize(input)); + + [Fact] + public void Declined_stays_rejected_and_does_not_become_withdrawn() + // Opposite directions: the employer declined you vs you pulled out. + => Assert.Equal("Rejected", JobPipeline.Normalize("declined")); + + [Fact] + public void Withdrawn_is_closed_and_not_a_prospect() + { + Assert.False(JobPipeline.IsProspect("Withdrawn")); + Assert.True(JobPipeline.IsCanonical("Withdrawn")); + } + + [Fact] + public void Ghosted_and_waiting_are_still_canonical_stages() + { + // Retained deliberately: the rules engine parks unanswered jobs in Ghosted, and Waiting + // carries its own follow-up rule. Removing either would strand that behaviour. + Assert.True(JobPipeline.IsCanonical("Ghosted")); + Assert.True(JobPipeline.IsCanonical("Waiting")); + } + + [Fact] + public void Board_groups_match_the_agreed_layout() + { + Assert.Equal(new[] { "Saved", "Interested", "Preparing" }, JobPipeline.StagesInGroup(PipelineGroup.NotApplied)); + Assert.Equal(new[] { "Applied", "Waiting", "Interview", "Offer" }, JobPipeline.StagesInGroup(PipelineGroup.Active)); + Assert.Equal(new[] { "Rejected", "Ghosted", "Withdrawn" }, JobPipeline.StagesInGroup(PipelineGroup.Closed)); + } + + [Fact] + public void Every_stage_belongs_to_exactly_one_group() + { + var grouped = JobPipeline.StagesInGroup(PipelineGroup.NotApplied) + .Concat(JobPipeline.StagesInGroup(PipelineGroup.Active)) + .Concat(JobPipeline.StagesInGroup(PipelineGroup.Closed)) + .ToList(); + + Assert.Equal(JobPipeline.Stages.Count, grouped.Count); + Assert.Equal(grouped.Count, grouped.Distinct().Count()); + } + + [Fact] + public void Offer_is_success_for_analytics_but_groups_under_active_for_the_board() + { + // The two axes deliberately disagree: StageAnalytics excludes Success from time-in-stage, + // but the user is still actively working an Offer. + var offer = JobPipeline.Stages.Single(s => s.Key == "Offer"); + Assert.Equal(PipelineCategory.Success, offer.Category); + Assert.Equal(PipelineGroup.Active, offer.Group); + } } diff --git a/JobTrackerApi.Tests/RulesEngineProspectTests.cs b/JobTrackerApi.Tests/RulesEngineProspectTests.cs new file mode 100644 index 0000000..a76f235 --- /dev/null +++ b/JobTrackerApi.Tests/RulesEngineProspectTests.cs @@ -0,0 +1,82 @@ +using System; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +/// +/// Guards the rule that matters most after Phase 0 added pre-application stages: a job the user +/// has not applied to must never be chased for a reply or auto-ghosted. Nothing was submitted, +/// so there is nobody to follow up with and nobody to be ghosted by. +/// +public sealed class RulesEngineProspectTests +{ + private static readonly RuleSettings AggressiveSettings = new() + { + Id = 1, + AppliedFollowUpDays = 1, + AppliedGhostDays = 2, + OfferFollowUpDays = 1, + OfferGhostDays = 2, + FeedbackFollowUpDays = 1, + FeedbackGhostDays = 2, + }; + + private static readonly DateTime Now = new(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + + [Theory] + [InlineData("Saved")] + [InlineData("Interested")] + [InlineData("Preparing")] + public void A_prospect_is_never_followed_up_or_ghosted_however_old(string status) + { + // Saved a year ago with thresholds of 1-2 days: an unguarded rule would ghost this. + var job = new JobApplication + { + Status = status, + DateApplied = null, + SavedAt = Now.AddDays(-365), + }; + + var decision = RulesEngine.Evaluate(AggressiveSettings, job, Now, lastMessageAt: null); + + Assert.False(decision.NeedsFollowUp); + Assert.False(decision.ShouldGhost); + } + + [Fact] + public void An_applied_job_with_no_applied_date_is_not_ghosted() + { + // Shouldn't happen (SyncAppliedDate stamps on the way into Applied), but a null must fail + // safe rather than be read as "infinitely old" and silently ghost the job. + var job = new JobApplication + { + Status = "Applied", + DateApplied = null, + SavedAt = Now.AddDays(-365), + }; + + var decision = RulesEngine.Evaluate(AggressiveSettings, job, Now, lastMessageAt: null); + + Assert.False(decision.NeedsFollowUp); + Assert.False(decision.ShouldGhost); + } + + [Fact] + public void An_applied_job_past_the_threshold_still_ghosts() + { + // The guards above must not have disabled the actual rule. + var job = new JobApplication + { + Status = "Applied", + DateApplied = Now.AddDays(-30), + SavedAt = Now.AddDays(-31), + }; + + var decision = RulesEngine.Evaluate(AggressiveSettings, job, Now, lastMessageAt: null); + + Assert.True(decision.NeedsFollowUp); + Assert.True(decision.ShouldGhost); + } +} diff --git a/JobTrackerApi/Controllers/ExportController.cs b/JobTrackerApi/Controllers/ExportController.cs index 2c1ba03..cf91796 100644 --- a/JobTrackerApi/Controllers/ExportController.cs +++ b/JobTrackerApi/Controllers/ExportController.cs @@ -77,7 +77,7 @@ namespace JobTrackerApi.Controllers Esc(j.Company?.Source), Esc(j.JobTitle), Esc(j.Status), - Esc(j.DateApplied.ToString("o")), + Esc(j.DateApplied?.ToString("o")), Esc(j.Location), Esc(j.Salary), Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)), diff --git a/JobTrackerApi/Controllers/JobApplicationDtos.cs b/JobTrackerApi/Controllers/JobApplicationDtos.cs index cd4cbaa..777d679 100644 --- a/JobTrackerApi/Controllers/JobApplicationDtos.cs +++ b/JobTrackerApi/Controllers/JobApplicationDtos.cs @@ -41,7 +41,11 @@ namespace JobTrackerApi.Controllers Company Company, string JobTitle, string Status, - DateTime DateApplied, + // Null while the job is in a pre-application (Prospect) stage — nothing submitted yet. + DateTime? DateApplied, + // When the user captured the job. Always set, so clients have a date to sort/show for + // jobs that have no DateApplied yet. + DateTime SavedAt, bool ResponseReceived, DateTime? ResponseDate, string? Notes, @@ -67,7 +71,8 @@ namespace JobTrackerApi.Controllers bool HasOtherAttachment, bool IsDeleted, DateTime? DeletedAt, - int DaysSince, + // Null when DateApplied is null: no elapsed time to report before applying. + int? DaysSince, bool NeedsFollowUp, string? FollowUpReason, string? TailoredCvText, @@ -129,7 +134,10 @@ namespace JobTrackerApi.Controllers public sealed record UpdateStatusRequest(string Status); - public sealed record PipelineStageDto(string Key, int Order, string Category); + // Category = analytics semantics (Prospect/Active/Success/Closed). + // Group = how the board collapses stages (NotApplied/Active/Closed). Different axes on purpose + // — Offer is Success but still actively worked. See JobPipeline.PipelineGroup. + public sealed record PipelineStageDto(string Key, int Order, string Category, string Group); public sealed record StatusSuggestionDto( bool HasSuggestion, @@ -152,7 +160,7 @@ namespace JobTrackerApi.Controllers 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 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( diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 0979c85..cbc93cb 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -417,6 +417,7 @@ Canonical profile: JobTitle: job.JobTitle, Status: job.Status, DateApplied: job.DateApplied, + SavedAt: job.SavedAt, ResponseReceived: job.ResponseReceived, ResponseDate: job.ResponseDate, Notes: job.Notes, @@ -754,6 +755,11 @@ Canonical profile: ResponseDate = null, }; + // A job created straight into a pre-application stage has not been applied to, so it + // must not carry an applied date. SyncAppliedDate also covers the reverse: a create + // that omits DateApplied but names a real stage still gets stamped. + JobPipeline.SyncAppliedDate(job, DateTime.Now); + (job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) = NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod); @@ -824,6 +830,8 @@ Canonical profile: job.CoverLetterText = request.CoverLetterText; job.JobUrl = NormalizeUrl(request.JobUrl); if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value; + // Status may have changed above; keep DateApplied consistent with the stage. + SyncAppliedDateWithHistory(job); if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate) { @@ -855,7 +863,7 @@ Canonical profile: /// Canonical ordered pipeline stages so the UI renders one source of truth. [HttpGet("pipeline")] public ActionResult> GetPipeline() - => Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString()))); + => Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString(), s.Group.ToString()))); [HttpPatch("{id:int}/status")] public async Task UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken) @@ -866,6 +874,9 @@ Canonical profile: if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required."); var old = job.Status; job.Status = JobPipeline.Normalize(request.Status); + // Stamps DateApplied when the job leaves the pre-application stages (e.g. the user + // drags Preparing -> Applied), and clears it if they move back. + SyncAppliedDateWithHistory(job); if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase)) { _db.JobEvents.Add(new JobEvent @@ -882,6 +893,33 @@ Canonical profile: return NoContent(); } + /// + /// Applies the stage/DateApplied invariant and preserves any discarded application date as + /// a JobEvent, so moving a job backwards into a pre-application stage never destroys the + /// record that it was once applied to. Both update paths route through here rather than + /// calling JobPipeline.SyncAppliedDate directly, so the history cannot be forgotten in one + /// of them. + /// + /// Not used by Create: there is no prior state to preserve there, only request + /// normalization. + /// + private void SyncAppliedDateWithHistory(JobApplication job) + { + var cleared = JobPipeline.SyncAppliedDate(job, DateTime.Now); + if (cleared is null) return; + + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = JobPipeline.AppliedDateClearedEvent, + // Round-trip format so the date is machine-readable, not just prose. + OldValue = cleared.Value.ToString("o"), + NewValue = null, + Note = $"Moved to {job.Status} before applying; application date cleared.", + At = DateTime.Now, + }); + } + /// /// 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. @@ -1150,9 +1188,11 @@ Canonical profile: startMonth = endMonth.AddMonths(-months); } + // DateApplied != null is explicit rather than implied by the range comparison: this is + // applied-volume-per-month, so jobs that have not been applied to must not appear. var jobs = await _db.JobApplications .AsNoTracking() - .Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth) + .Where(j => !j.IsDeleted && j.DateApplied != null && j.DateApplied >= startMonth && j.DateApplied < endMonth) .Select(j => new { j.DateApplied, j.ResponseDate }) .ToListAsync(cancellationToken); @@ -1163,7 +1203,7 @@ Canonical profile: foreach (var j in jobs) { - var ak = Key(j.DateApplied); + var ak = Key(j.DateApplied!.Value); applied[ak] = (applied.TryGetValue(ak, out var av) ? av : 0) + 1; if (j.ResponseDate is not null) @@ -2139,7 +2179,7 @@ Candidate master CV: var subject = BuildFollowUpSubject(job, lastMessage); var reference = lastMessage?.Subject ?? job.JobTitle; var summary = job.ShortSummary; - var appliedDate = job.DateApplied.ToString("MMMM d, yyyy"); + var appliedDate = job.DateApplied?.ToString("MMMM d, yyyy") ?? "not yet applied"; var tagHighlights = SplitTags(job.Tags).Take(4).ToList(); var companyName = job.Company?.Name ?? "your team"; var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); diff --git a/JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.Designer.cs b/JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.Designer.cs new file mode 100644 index 0000000..94a0d3a --- /dev/null +++ b/JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.Designer.cs @@ -0,0 +1,1387 @@ +// +using System; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + [DbContext(typeof(JobTrackerContext))] + [Migration("20260717071417_AddJobEntityAndProspectStages")] + partial class AddJobEntityAndProspectStages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AvatarImageDataUrl") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentCvExtractionRunId") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvUploadArtifactId") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("GoogleEmail") + .HasColumnType("TEXT"); + + b.Property("GoogleLinkedAt") + .HasColumnType("TEXT"); + + b.Property("GoogleSubject") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfileCvStructureJson") + .HasColumnType("TEXT"); + + b.Property("ProfileCvText") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Purpose") + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .HasColumnType("TEXT"); + + b.Property("UseForAi") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastContactedAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextContactAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PipelineStage") + .HasColumnType("TEXT"); + + b.Property("RecruiterEmail") + .HasColumnType("TEXT"); + + b.Property("RecruiterLinkedIn") + .HasColumnType("TEXT"); + + b.Property("RecruiterName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentMetadataJson") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("ExternalFrom") + .HasColumnType("TEXT"); + + b.Property("ExternalLabelsJson") + .HasColumnType("TEXT"); + + b.Property("ExternalMessageId") + .HasColumnType("TEXT"); + + b.Property("ExternalThreadId") + .HasColumnType("TEXT"); + + b.Property("ExternalTo") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Correspondences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ArtifactId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LlmPromptVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedText") + .HasColumnType("TEXT"); + + b.Property("NormalizerVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawExtractedText") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StructuredProfileJson") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtifactId"); + + b.HasIndex("OwnerUserId", "StartedAtUtc"); + + b.ToTable("CvExtractionRuns"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "UploadedAtUtc"); + + b.ToTable("CvUploadArtifacts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GmailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "GmailAddress") + .IsUnique(); + + b.ToTable("GmailConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GmailReviewDecisions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UseSsl") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CoverLetterText") + .HasColumnType("TEXT"); + + b.Property("DateApplied") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("FeedbackRequestedAt") + .HasColumnType("TEXT"); + + b.Property("FollowUpAt") + .HasColumnType("TEXT"); + + b.Property("HasCoverLetter") + .HasColumnType("INTEGER"); + + b.Property("HasOtherAttachment") + .HasColumnType("INTEGER"); + + b.Property("HasPortfolio") + .HasColumnType("INTEGER"); + + b.Property("HasResume") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("LastReminderEmailSentAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("NextAction") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("RecruiterMessageDraft") + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseReceived") + .HasColumnType("INTEGER"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TailoredCvText") + .HasColumnType("TEXT"); + + b.Property("TailoredCvUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "FollowUpAt"); + + b.HasIndex("OwnerUserId", "IsDeleted"); + + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NewValue") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OldValue") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MicrosoftGraphConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RuleSettings"); + + b.HasData( + new + { + Id = 1, + AppliedFollowUpDays = 14, + AppliedGhostDays = 30, + FeedbackFollowUpDays = 7, + FeedbackGhostDays = 14, + OfferFollowUpDays = 7, + OfferGhostDays = 14 + }); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SystemEmailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasColumnType("TEXT"); + + b.Property("SmtpEnableSsl") + .HasColumnType("INTEGER"); + + b.Property("SmtpHost") + .HasColumnType("TEXT"); + + b.Property("SmtpPassword") + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpTimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("SmtpUser") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemEmailSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CustomSectionsJson") + .HasColumnType("TEXT"); + + b.Property("EducationJson") + .HasColumnType("TEXT"); + + b.Property("ExperienceJson") + .HasColumnType("TEXT"); + + b.Property("GenerationContextHash") + .HasColumnType("TEXT"); + + b.Property("Headline") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LastEditedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastGeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RenderOptionsJson") + .HasColumnType("TEXT"); + + b.Property("SelectedSkillsJson") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SummaryJson") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => + { + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("OwnerUserId"); + + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Attachments") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Messages") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") + .WithMany() + .HasForeignKey("ArtifactId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Artifact"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany("Jobs") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.Job", "Job") + .WithMany("Applications") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Company"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Events") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithOne("TailoredCvDraft") + .HasForeignKey("JobTrackerApi.Models.TailoredCvDraft", "JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Navigation("Attachments"); + + b.Navigation("Events"); + + b.Navigation("Messages"); + + b.Navigation("TailoredCvDraft"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.cs b/JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.cs new file mode 100644 index 0000000..c24511f --- /dev/null +++ b/JobTrackerApi/Migrations/20260717071417_AddJobEntityAndProspectStages.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + /// Phase 0 foundation change (2026-07-17): + /// - adds the Job entity (the opportunity), additively; nothing reads it yet, + /// - adds JobApplication.JobId (nullable FK) so the split can be completed later, + /// - adds JobApplication.SavedAt (when the user captured the job), + /// - makes JobApplication.DateApplied nullable so a job can be tracked before it is + /// applied to (see the Prospect stages in JobPipeline). + /// + /// HAND-EDITED after scaffolding. `dotnet ef migrations add` also emitted CreateTable for + /// TrustedDevices / TwoFactorRecoveryCodes / UserSessions and AddColumn for the AspNetUsers + /// Microsoft*/Totp* columns. Those were removed: they already exist in every real database, + /// having been provisioned by the idempotent reconciler in StartupInitializationExtensions + /// rather than by a migration, so the prior ModelSnapshot did not know about them and the + /// scaffolder diffed them as missing. Re-creating them would fail with "table already + /// exists" on any existing database. The reconciler still creates them on a fresh boot + /// (CREATE TABLE IF NOT EXISTS), which is how this repo has always provisioned them. + /// + /// IX_JobApplications_OwnerUserId_IsDeleted_Status was dropped from this migration for the + /// same reason: the reconciler applies it, and on MySQL it needs a Status(50) prefix length + /// that this scaffolded DDL does not carry (see JobTrackerContext.OnModelCreating). + /// + /// See docs/decisions/ADR-002-job-application-model.md. + /// + public partial class AddJobEntityAndProspectStages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // A job in a pre-application stage (Saved/Interested/Preparing) has no applied date. + // On SQLite this is a table rebuild; EF carries over every column it knows about, and + // all reconciler-added JobApplications columns are present in the model. + migrationBuilder.AlterColumn( + name: "DateApplied", + table: "JobApplications", + type: "TEXT", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "JobId", + table: "JobApplications", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "SavedAt", + table: "JobApplications", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + // Backfill: every pre-existing row is an applied job, so the best available estimate + // of when it was captured is when it was applied. Without this each historic row keeps + // the scaffolded 0001-01-01 sentinel, which would show as a bogus saved date and skew + // the stage-entry maths that falls back to SavedAt. + migrationBuilder.Sql("UPDATE JobApplications SET SavedAt = DateApplied WHERE DateApplied IS NOT NULL;"); + + migrationBuilder.CreateTable( + name: "Jobs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + OwnerUserId = table.Column(type: "TEXT", nullable: true), + CompanyId = table.Column(type: "INTEGER", nullable: false), + JobTitle = table.Column(type: "TEXT", nullable: false), + Location = table.Column(type: "TEXT", nullable: true), + JobUrl = table.Column(type: "TEXT", nullable: true), + Description = table.Column(type: "TEXT", nullable: true), + TranslatedDescription = table.Column(type: "TEXT", nullable: true), + DescriptionLanguage = table.Column(type: "TEXT", nullable: true), + ShortSummary = table.Column(type: "TEXT", nullable: true), + Tags = table.Column(type: "TEXT", nullable: true), + Deadline = table.Column(type: "TEXT", nullable: true), + Salary = table.Column(type: "TEXT", nullable: true), + SalaryMin = table.Column(type: "TEXT", nullable: true), + SalaryMax = table.Column(type: "TEXT", nullable: true), + SalaryCurrency = table.Column(type: "TEXT", nullable: true), + SalaryPeriod = table.Column(type: "TEXT", nullable: true), + Source = table.Column(type: "TEXT", nullable: true), + CountryCode = table.Column(type: "TEXT", nullable: true), + SavedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Jobs", x => x.Id); + table.ForeignKey( + name: "FK_Jobs_Companies_CompanyId", + column: x => x.CompanyId, + principalTable: "Companies", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobApplications_JobId", + table: "JobApplications", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_CompanyId", + table: "Jobs", + column: "CompanyId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_OwnerUserId", + table: "Jobs", + column: "OwnerUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_JobApplications_Jobs_JobId", + table: "JobApplications", + column: "JobId", + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_JobApplications_Jobs_JobId", + table: "JobApplications"); + + migrationBuilder.DropTable( + name: "Jobs"); + + migrationBuilder.DropIndex( + name: "IX_JobApplications_JobId", + table: "JobApplications"); + + // Reverting DateApplied to NOT NULL requires every row to have one. Rows sitting in a + // pre-application stage legitimately do not, so fall back to SavedAt before the ALTER + // rather than letting it fail. Runs before SavedAt is dropped. This direction is lossy + // by nature (an unapplied job gains an applied date) and exists for local rollback. + migrationBuilder.Sql("UPDATE JobApplications SET DateApplied = SavedAt WHERE DateApplied IS NULL;"); + + migrationBuilder.AlterColumn( + name: "DateApplied", + table: "JobApplications", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.DropColumn( + name: "SavedAt", + table: "JobApplications"); + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index f1b13af..99ed8a0 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -72,6 +72,15 @@ namespace JobTrackerApi.Migrations b.Property("LockoutEnd") .HasColumnType("TEXT"); + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + b.Property("NormalizedEmail") .HasMaxLength(256) .HasColumnType("TEXT"); @@ -98,6 +107,15 @@ namespace JobTrackerApi.Migrations b.Property("SecurityStamp") .HasColumnType("TEXT"); + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + b.Property("TwoFactorEnabled") .HasColumnType("INTEGER"); @@ -154,7 +172,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("JobApplicationId"); - b.ToTable("Attachments", (string)null); + b.ToTable("Attachments"); }); modelBuilder.Entity("JobTrackerApi.Models.Company", b => @@ -198,7 +216,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("OwnerUserId"); - b.ToTable("Companies", (string)null); + b.ToTable("Companies"); }); modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => @@ -255,7 +273,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("JobApplicationId"); - b.ToTable("Correspondences", (string)null); + b.ToTable("Correspondences"); }); modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => @@ -318,7 +336,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("OwnerUserId", "StartedAtUtc"); - b.ToTable("CvExtractionRuns", (string)null); + b.ToTable("CvExtractionRuns"); }); modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => @@ -361,7 +379,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("OwnerUserId", "UploadedAtUtc"); - b.ToTable("CvUploadArtifacts", (string)null); + b.ToTable("CvUploadArtifacts"); }); modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => @@ -423,7 +441,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("OwnerUserId", "GmailAddress") .IsUnique(); - b.ToTable("GmailConnections", (string)null); + b.ToTable("GmailConnections"); }); modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => @@ -455,7 +473,7 @@ namespace JobTrackerApi.Migrations b.HasKey("Id"); - b.ToTable("GmailReviewDecisions", (string)null); + b.ToTable("GmailReviewDecisions"); }); modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => @@ -512,7 +530,80 @@ namespace JobTrackerApi.Migrations b.HasKey("Id"); - b.ToTable("ImapConnections", (string)null); + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); }); modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => @@ -527,7 +618,7 @@ namespace JobTrackerApi.Migrations b.Property("CoverLetterText") .HasColumnType("TEXT"); - b.Property("DateApplied") + b.Property("DateApplied") .HasColumnType("TEXT"); b.Property("Deadline") @@ -563,6 +654,9 @@ namespace JobTrackerApi.Migrations b.Property("IsDeleted") .HasColumnType("INTEGER"); + b.Property("JobId") + .HasColumnType("INTEGER"); + b.Property("JobTitle") .IsRequired() .HasColumnType("TEXT"); @@ -609,6 +703,9 @@ namespace JobTrackerApi.Migrations b.Property("SalaryPeriod") .HasColumnType("TEXT"); + b.Property("SavedAt") + .HasColumnType("TEXT"); + b.Property("ShortSummary") .HasColumnType("TEXT"); @@ -632,13 +729,17 @@ namespace JobTrackerApi.Migrations b.HasIndex("CompanyId"); + b.HasIndex("JobId"); + b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId", "FollowUpAt"); b.HasIndex("OwnerUserId", "IsDeleted"); - b.ToTable("JobApplications", (string)null); + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); }); modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => @@ -670,7 +771,7 @@ namespace JobTrackerApi.Migrations b.HasIndex("JobApplicationId"); - b.ToTable("JobEvents", (string)null); + b.ToTable("JobEvents"); }); modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => @@ -727,7 +828,7 @@ namespace JobTrackerApi.Migrations b.HasKey("Id"); - b.ToTable("MicrosoftGraphConnections", (string)null); + b.ToTable("MicrosoftGraphConnections"); }); modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => @@ -756,7 +857,7 @@ namespace JobTrackerApi.Migrations b.HasKey("Id"); - b.ToTable("RuleSettings", (string)null); + b.ToTable("RuleSettings"); b.HasData( new @@ -806,7 +907,7 @@ namespace JobTrackerApi.Migrations b.HasKey("Id"); - b.ToTable("SystemEmailSettings", (string)null); + b.ToTable("SystemEmailSettings"); }); modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => @@ -871,7 +972,69 @@ namespace JobTrackerApi.Migrations b.HasIndex("OwnerUserId", "JobApplicationId") .IsUnique(); - b.ToTable("TailoredCvDrafts", (string)null); + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); }); modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => @@ -899,7 +1062,38 @@ namespace JobTrackerApi.Migrations b.HasKey("OwnerUserId"); - b.ToTable("UserRuleSettings", (string)null); + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => @@ -1062,6 +1256,17 @@ namespace JobTrackerApi.Migrations b.Navigation("Artifact"); }); + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => { b.HasOne("JobTrackerApi.Models.Company", "Company") @@ -1070,7 +1275,14 @@ namespace JobTrackerApi.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("JobTrackerApi.Models.Job", "Job") + .WithMany("Applications") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Company"); + + b.Navigation("Job"); }); modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => @@ -1151,6 +1363,11 @@ namespace JobTrackerApi.Migrations b.Navigation("Jobs"); }); + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Navigation("Applications"); + }); + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => { b.Navigation("Attachments"); diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index e6bfb4a..88329fa 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -146,6 +146,7 @@ builder.Services.AddHttpClient("jobimport") }); // Local AI service (FastAPI). Supports summarization and OCR/text extraction. +// Every caller goes through this named client, so the shared-secret header is set once here. builder.Services.AddHttpClient("ai-service", client => { var baseUrl = builder.Configuration["Ai:BaseUrl"] @@ -153,6 +154,12 @@ builder.Services.AddHttpClient("ai-service", client => ?? "http://127.0.0.1:8001"; client.BaseAddress = new Uri(baseUrl); client.Timeout = TimeSpan.FromSeconds(30); + + var serviceToken = builder.Configuration["Ai:ServiceToken"]; + if (!string.IsNullOrWhiteSpace(serviceToken)) + { + client.DefaultRequestHeaders.Add("X-Ai-Service-Token", serviceToken); + } }); builder.Services.AddMemoryCache(); diff --git a/JobTrackerApi/Services/AnalyticsService.cs b/JobTrackerApi/Services/AnalyticsService.cs index 06ddffd..5555066 100644 --- a/JobTrackerApi/Services/AnalyticsService.cs +++ b/JobTrackerApi/Services/AnalyticsService.cs @@ -45,11 +45,13 @@ namespace JobTrackerApi.Services // 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. + // DateApplied is null for pre-application stages; those have no "days since applied" + // and are filtered out server-side so they can't drag the average toward zero. var activeDates = active == 0 ? new List() : await _db.JobApplications.AsNoTracking() - .Where(j => !j.IsDeleted) - .Select(j => j.DateApplied) + .Where(j => !j.IsDeleted && j.DateApplied != null) + .Select(j => j.DateApplied!.Value) .ToListAsync(cancellationToken); var avgDays = activeDates.Count == 0 @@ -80,6 +82,7 @@ namespace JobTrackerApi.Services j.ResponseReceived, j.ResponseDate, j.DateApplied, + j.SavedAt, j.CompanyId, CompanyName = j.Company.Name, CompanySource = j.Company.Source @@ -122,9 +125,11 @@ namespace JobTrackerApi.Services .Take(8) .ToList(); + // "Days to respond" is only meaningful once applied, so rows with no DateApplied + // (pre-application stages) are excluded rather than measured from nothing. var responseDays = activeJobs - .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null) - .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays)) + .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null && j.DateApplied is not null) + .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied!.Value).TotalDays)) .OrderBy(x => x) .ToList(); @@ -153,7 +158,9 @@ namespace JobTrackerApi.Services var occupancy = activeJobs.Select(job => { var current = JobPipeline.Normalize(job.Status); - DateTime enteredAt = job.DateApplied; + // Fall back to SavedAt when the job has not been applied to: every job has a + // saved date, so a stage entry time always exists even before DateApplied does. + DateTime enteredAt = job.DateApplied ?? job.SavedAt; if (lastEntryByJob.TryGetValue(job.Id, out var changes)) { var lastIntoCurrent = changes diff --git a/JobTrackerApi/Services/FollowUpReminderHostedService.cs b/JobTrackerApi/Services/FollowUpReminderHostedService.cs index f632fd3..bb881c5 100644 --- a/JobTrackerApi/Services/FollowUpReminderHostedService.cs +++ b/JobTrackerApi/Services/FollowUpReminderHostedService.cs @@ -92,7 +92,9 @@ public sealed class FollowUpReminderHostedService : BackgroundService var followMode = SuggestFollowUpMode(job.Status); var detailsUrl = $"{baseUrl}/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}"; var companyName = job.Company?.Name ?? "Unknown company"; - var appliedOn = job.DateApplied.ToString("MMMM d, yyyy"); + // RulesEngine never raises a follow-up for a job with no DateApplied, so this should + // always have a value; the fallback just keeps the email readable rather than throwing. + var appliedOn = job.DateApplied?.ToString("MMMM d, yyyy") ?? "an unrecorded date"; var subject = $"Follow up reminder: {job.JobTitle} at {companyName}"; var body = string.Join("\n\n", new[] { diff --git a/JobTrackerApi/Services/JobPipeline.cs b/JobTrackerApi/Services/JobPipeline.cs index a4ca34e..b14f37f 100644 --- a/JobTrackerApi/Services/JobPipeline.cs +++ b/JobTrackerApi/Services/JobPipeline.cs @@ -2,12 +2,32 @@ namespace JobTrackerApi.Services { public enum PipelineCategory { + /// + /// Pre-application: the user is tracking the opportunity but has not applied yet. + /// Nothing in these stages has been submitted, so follow-up/ghosting rules and + /// applied-volume analytics must never count them. + /// + Prospect, Active, Success, Closed, } - public sealed record PipelineStage(string Key, int Order, PipelineCategory Category); + /// + /// How stages collapse on the board. Deliberately NOT the same axis as + /// : Category carries analytics semantics (Offer is Success, so + /// StageAnalytics excludes it from "how long has this been stuck"), while Group is what the + /// user sees (Offer is still something you are actively working, so it groups under Active). + /// Collapsing the two would force one to lie. + /// + public enum PipelineGroup + { + NotApplied, + Active, + Closed, + } + + public sealed record PipelineStage(string Key, int Order, PipelineCategory Category, PipelineGroup Group); /// /// Canonical job-application pipeline: the single source of truth for the ordered set of @@ -17,24 +37,68 @@ namespace JobTrackerApi.Services /// public static class JobPipeline { + /// + /// Fallback for an empty status on the legacy create path, which historically meant + /// "already applied". New pre-application flows should pass + /// explicitly rather than relying on this. + /// public const string DefaultStatus = "Applied"; + /// Entry stage for a job captured before the user has applied. + public const string SavedStatus = "Saved"; + + /// + /// The detailed internal stages. The board groups these (see ) + /// rather than showing ten columns. + /// + /// Waiting and Ghosted are retained deliberately. Ghosted is where the rules engine parks + /// a job that was never answered — it is neither Rejected (nobody rejected you) nor + /// Withdrawn (you did not withdraw), and removing it would leave auto-ghosting with no + /// target stage. Waiting carries its own follow-up rule and reminder wording. + /// public static readonly IReadOnlyList Stages = new List { - new("Applied", 1, PipelineCategory.Active), - new("Waiting", 2, PipelineCategory.Active), - new("Interview", 3, PipelineCategory.Active), - new("Offer", 4, PipelineCategory.Success), - new("Rejected", 5, PipelineCategory.Closed), - new("Ghosted", 6, PipelineCategory.Closed), + new("Saved", 1, PipelineCategory.Prospect, PipelineGroup.NotApplied), + new("Interested", 2, PipelineCategory.Prospect, PipelineGroup.NotApplied), + new("Preparing", 3, PipelineCategory.Prospect, PipelineGroup.NotApplied), + new("Applied", 4, PipelineCategory.Active, PipelineGroup.Active), + new("Waiting", 5, PipelineCategory.Active, PipelineGroup.Active), + new("Interview", 6, PipelineCategory.Active, PipelineGroup.Active), + new("Offer", 7, PipelineCategory.Success, PipelineGroup.Active), + new("Rejected", 8, PipelineCategory.Closed, PipelineGroup.Closed), + new("Ghosted", 9, PipelineCategory.Closed, PipelineGroup.Closed), + new("Withdrawn", 10, PipelineCategory.Closed, PipelineGroup.Closed), }; + /// Stage keys in a group, in pipeline order. + public static IReadOnlyList StagesInGroup(PipelineGroup group) + => Stages.Where(s => s.Group == group).OrderBy(s => s.Order).Select(s => s.Key).ToList(); + + /// True when the status is a pre-application stage (nothing submitted yet). + public static bool IsProspect(string? status) + { + var normalized = Normalize(status); + var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase)); + // Unknown custom statuses are NOT treated as prospects: they predate this split and + // have always been counted as applied. Assuming otherwise would silently drop them + // out of existing users' analytics. + return stage?.Category == PipelineCategory.Prospect; + } + private static readonly Dictionary Canonical = Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase); // Legacy/synonym spellings that should collapse onto a canonical stage. private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) { + ["bookmarked"] = "Saved", + ["wishlist"] = "Saved", + ["to apply"] = "Saved", + ["shortlisted"] = "Interested", + ["considering"] = "Interested", + ["in preparation"] = "Preparing", + ["preparing application"] = "Preparing", + ["drafting"] = "Preparing", ["interviewing"] = "Interview", ["interviews"] = "Interview", ["interviewed"] = "Interview", @@ -46,6 +110,11 @@ namespace JobTrackerApi.Services ["no response"] = "Ghosted", ["no reply"] = "Ghosted", ["declined"] = "Rejected", + // "declined" stays mapped to Rejected above (the employer declined you). Withdrawn is + // the opposite direction — the user pulled out — so it takes only unambiguous spellings. + ["withdrew"] = "Withdrawn", + ["cancelled"] = "Withdrawn", + ["canceled"] = "Withdrawn", }; /// @@ -65,6 +134,39 @@ namespace JobTrackerApi.Services public static bool IsCanonical(string? status) => !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim()); + /// + /// Enforces the invariant "DateApplied is set if and only if the job has left the + /// pre-application stages". Call after any write to ; + /// every status-write path routes through here so they cannot drift apart. + /// + /// Moving backwards into a Prospect stage clears DateApplied. That is deliberate: the + /// alternative — a Saved job still carrying an applied date — silently counts it as + /// applied in analytics and exposes it to the follow-up/ghosting rules. + /// + /// Returns the date that was cleared, or null if nothing was cleared. Callers persist it + /// as an (Type = ) so + /// the application activity survives the clear — this method has no DbContext, so it + /// reports what it did rather than recording it. + /// + public static DateTime? SyncAppliedDate(Models.JobApplication job, DateTime nowUtc) + { + if (IsProspect(job.Status)) + { + var cleared = job.DateApplied; + job.DateApplied = null; + return cleared; + } + + job.DateApplied ??= nowUtc; + return null; + } + + /// + /// JobEvent.Type for a DateApplied cleared by a backwards move into a Prospect stage. + /// OldValue holds the round-tripped date so the history is machine-readable, not just prose. + /// + public const string AppliedDateClearedEvent = "AppliedDateCleared"; + public static int OrderOf(string? status) { var normalized = Normalize(status); diff --git a/JobTrackerApi/Services/RulesEngine.cs b/JobTrackerApi/Services/RulesEngine.cs index 9592d63..1cf8918 100644 --- a/JobTrackerApi/Services/RulesEngine.cs +++ b/JobTrackerApi/Services/RulesEngine.cs @@ -47,9 +47,14 @@ namespace JobTrackerApi.Services var status = job.Status ?? "Applied"; if (status == "Interviewing") status = "Interview"; + // Nothing has been submitted in a pre-application stage, so there is nobody to chase + // and nobody to be ghosted by. Guard before any date maths: a Saved job has no + // DateApplied, and treating that as "very old" would silently auto-ghost it. + if (JobPipeline.IsProspect(status)) return new FollowUpDecision(false, null, false); + // Last activity: any explicit follow-up date, response date, feedback request, or correspondence message. var last = Max( - job.DateApplied, + job.DateApplied ?? job.SavedAt, job.ResponseDate, job.FollowUpAt, job.FeedbackRequestedAt, @@ -61,7 +66,11 @@ namespace JobTrackerApi.Services // Applied: if no response and enough time passed since applied. if (string.Equals(status, "Applied", StringComparison.OrdinalIgnoreCase) && !job.ResponseReceived) { - var daysSinceApplied = (now - job.DateApplied).TotalDays; + // An Applied job should always have DateApplied. Fail safe rather than fall back to + // a synthetic date, which could ghost the job on the next rules pass. + if (job.DateApplied is null) return new FollowUpDecision(false, null, false); + + var daysSinceApplied = (now - job.DateApplied.Value).TotalDays; if (daysSinceApplied >= s.AppliedFollowUpDays) return new FollowUpDecision(true, $"No reply after {s.AppliedFollowUpDays}d", daysSinceApplied >= s.AppliedGhostDays); return new FollowUpDecision(false, null, daysSinceApplied >= s.AppliedGhostDays); diff --git a/Models/Job.cs b/Models/Job.cs new file mode 100644 index 0000000..4334402 --- /dev/null +++ b/Models/Job.cs @@ -0,0 +1,57 @@ +using System; + +namespace JobTrackerApi.Models +{ + /// + /// A job opportunity — the role as the employer posted it, independent of whether the user + /// has applied. Separated from (the user's pursuit of a job) so + /// that: + /// - a job can be tracked before it is applied to (see JobPipeline's Prospect stages), and + /// - applying twice to the same role does not duplicate the whole job description. + /// + /// Introduced in Phase 0 (2026-07-17) as an additive step. Nothing reads from this table yet: + /// still carries its own copy of these columns and remains the + /// source of truth for all current reads and writes. The cutover (dual-write, then flip reads, + /// then drop the legacy columns) is Phase 1 work. + /// + /// See docs/decisions/ADR-002-job-application-model.md. + /// + public class Job + { + public int Id { get; set; } + public string? OwnerUserId { get; set; } + + public int CompanyId { get; set; } + public Company Company { get; set; } = null!; + + public string JobTitle { get; set; } = ""; + public string? Location { get; set; } + public string? JobUrl { get; set; } + + // Imported job content. + public string? Description { get; set; } + public string? TranslatedDescription { get; set; } + public string? DescriptionLanguage { get; set; } // "en", "no", ... + public string? ShortSummary { get; set; } + public string? Tags { get; set; } // JSON array string, e.g. ["Azure","Docker"] + public DateTime? Deadline { get; set; } + + // Structured salary; the free-text Salary field is kept for display/back-compat. + public string? Salary { get; set; } + public decimal? SalaryMin { get; set; } + public decimal? SalaryMax { get; set; } + public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR" + public string? SalaryPeriod { get; set; } // "year" | "month" | "hour" + + // Where this job came from. Market is data, not an assumption: Jobjakt is Norway-first + // but must not hardcode Norway, so country travels with the job rather than being + // implied by the importer. Null = unknown/manually entered. + public string? Source { get; set; } // e.g. "finn", "nav", "linkedin", "manual" + public string? CountryCode { get; set; } // ISO 3166-1 alpha-2, e.g. "NO", "GB" + + /// When the user first captured this opportunity. + public DateTime SavedAt { get; set; } = DateTime.UtcNow; + + public List Applications { get; set; } = new(); + } +} diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index 0849e62..15caaff 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -9,8 +9,26 @@ public class JobApplication public string JobTitle { get; set; } = ""; public int CompanyId { get; set; } public Company Company { get; set; } = null!; + + // The opportunity this application is for. Nullable and unused for now: Phase 0 added the + // Job entity additively and JobApplication still owns the opportunity columns below. + // See Models/Job.cs and docs/decisions/ADR-002-job-application-model.md. + public int? JobId { get; set; } + public Job? Job { get; set; } + public string Status { get; set; } = "Applied"; - public DateTime DateApplied { get; set; } = DateTime.UtcNow; + + /// + /// When the user submitted the application. Null while the job is still in a pre-application + /// (Prospect) stage — Saved/Interested/Preparing — because nothing has been submitted yet. + /// Callers must not synthesise a date for unapplied jobs: a fake DateApplied feeds the + /// follow-up/ghosting rules and the applied-volume analytics. + /// + public DateTime? DateApplied { get; set; } + + /// When the user first captured this job. Always set. + public DateTime SavedAt { get; set; } = DateTime.UtcNow; + public string? Location { get; set; } public string? Salary { get; set; } @@ -59,6 +77,13 @@ public class JobApplication public List Attachments { get; set; } = new(); public List Events { get; set; } = new(); - public int DaysSince => ((ResponseReceived ? (ResponseDate ?? DateTime.UtcNow) : DateTime.UtcNow) - DateApplied.ToUniversalTime()).Days; + /// + /// Days since the application was submitted. Null for pre-application (Prospect) stages: + /// with no DateApplied there is no elapsed time to report, and 0 would read as + /// "applied today". + /// + public int? DaysSince => DateApplied is null + ? null + : ((ResponseReceived ? (ResponseDate ?? DateTime.UtcNow) : DateTime.UtcNow) - DateApplied.Value.ToUniversalTime()).Days; } } diff --git a/docker-compose.yml b/docker-compose.yml index 738e862..200b9e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,9 @@ services: - Microsoft__RedirectUri=${MICROSOFT_REDIRECT_URI} - Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001} - Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001} + # Shared secret for calls to ai-service. Must match AI_SERVICE_TOKEN below. + # Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map. + - "Ai__ServiceToken=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}" # Email (SMTP) # Build metadata should be resolved before deployment. Examples: # APP_VERSION=1.0.0 @@ -55,6 +58,9 @@ services: networks: - default - shared_services + # The only other member of ai_internal — the backend is the sole permitted caller of + # ai-service. + - ai_internal restart: unless-stopped frontend: @@ -95,11 +101,25 @@ services: - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash} - GROQ_API_KEY=${GROQ_API_KEY:-} - GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile} - ports: - - "8001:8001" + # Shared secret required on every endpoint except /health. Must match Ai__ServiceToken + # on the backend. `:?` so a deploy that forgets it fails loudly instead of booting open. + # Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map. + - "AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}" + # Deliberately NOT published to the host: this service has no user auth and can spend a + # paid provider's API key (AI_PROVIDER=gemini/groq). The backend reaches it in-network at + # http://ai-service:8001. To debug locally, use docker-compose.override.yml rather than + # re-adding a `ports:` here. + expose: + - "8001" + # ai_internal ONLY. Not on `default` (which the frontend shares) and not on + # `shared_services` (which is `external: true`, so any other compose stack on this host can + # join it and would then be able to reach this service). ai_internal carries exactly two + # members: this service and the backend. Nothing else can route to port 8001. + # + # The network is NOT marked `internal: true` — ai-service still needs egress to + # generativelanguage.googleapis.com / api.groq.com when AI_PROVIDER is gemini or groq. networks: - - default - - shared_services + - ai_internal restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5).read()"] @@ -119,9 +139,16 @@ services: - OLLAMA_HOST=0.0.0.0:11434 volumes: - ollama_data:/root/.ollama + # On ai_internal so the bundled Ollama stays reachable at http://ollama:11434 now that + # ai-service has left the `default`/`shared_services` networks. + # + # NOTE: if you point OLLAMA_BASE_URL at an Ollama running in ANOTHER compose stack, address + # it by host IP (e.g. http://:11435) — ai-service can no longer resolve container + # names on `shared_services`, by design. networks: - default - shared_services + - ai_internal restart: unless-stopped gpus: all healthcheck: @@ -139,3 +166,10 @@ networks: shared_services: external: true name: jobtracker_shared + + # Private backend <-> ai-service link. Deliberately NOT external: nothing outside this compose + # project can join it, so ai-service is unreachable from the host, from the frontend, and from + # any other stack sharing jobtracker_shared. Egress to cloud AI providers still works because + # this is a normal bridge (not `internal: true`). + ai_internal: + driver: bridge diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index 7375421..1c764e7 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -111,7 +111,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setStatus(j.status ?? "Applied"); setInitialStatus(j.status ?? "Applied"); setStatusChangedAt(new Date().toISOString().slice(0, 10)); - setDateApplied(toDateInputValue(j.dateApplied)); + setDateApplied(toDateInputValue(j.dateApplied ?? undefined)); setLocation(j.location ?? ""); setSalary(j.salary ?? ""); setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : ""); diff --git a/job-tracker-ui/src/components/ImportExportJobs.tsx b/job-tracker-ui/src/components/ImportExportJobs.tsx index bd9e894..d6dc0a4 100644 --- a/job-tracker-ui/src/components/ImportExportJobs.tsx +++ b/job-tracker-ui/src/components/ImportExportJobs.tsx @@ -7,7 +7,9 @@ import { Company, JobApplication } from "../types"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; -type ImportJob = Omit & { +// savedAt is omitted alongside id: both are assigned by the server on create, so an imported +// row has no business supplying one. +type ImportJob = Omit & { company: Pick; }; diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 4dfbbb5..e9806af 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -723,8 +723,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {t("jobDetailsStrategySnapshotEmpty")} )} - {t("jobDetailsDateApplied")}{job ? new Date(job.dateApplied).toLocaleDateString() : ""} - {t("jobDetailsDaysSince")}{job?.daysSince ?? ""} + {t("jobDetailsDateApplied")}{job?.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—"} + {t("jobDetailsDaysSince")}{job?.daysSince ?? "—"} {t("jobTableLocation")}{job?.location ?? ""} {t("jobDetailsSalary")}{job?.salary ?? ""} {t("jobDetailsNextAction")}{job?.nextAction ?? ""} diff --git a/job-tracker-ui/src/components/JobFlowBar.tsx b/job-tracker-ui/src/components/JobFlowBar.tsx index eb34bbb..a53102b 100644 --- a/job-tracker-ui/src/components/JobFlowBar.tsx +++ b/job-tracker-ui/src/components/JobFlowBar.tsx @@ -72,7 +72,9 @@ export default function JobFlowBar({ job, history = [] }: { job: JobApplication const items = useMemo(() => { if (!job) return [] as FlowItem[]; - const appliedAt = new Date(job.dateApplied); + // Null for a job that has not been applied to yet: there is no Applied milestone to anchor + // the flow on, so the bar simply starts later. + const appliedAt = job.dateApplied ? new Date(job.dateApplied) : null; const replyAt = firstResponse(history, job); const interviewAt = firstStatusChange(history, "Interview") || (normalizeStatus(job.status) === "Interview" ? replyAt ?? appliedAt : null); const offerAt = firstStatusChange(history, "Offer") || (normalizeStatus(job.status) === "Offer" ? replyAt ?? interviewAt ?? appliedAt : null); @@ -81,15 +83,17 @@ export default function JobFlowBar({ job, history = [] }: { job: JobApplication || firstStatusChange(history, "Ghosted") || ((normalizeStatus(job.status) === "Rejected" || normalizeStatus(job.status) === "Ghosted") ? replyAt ?? appliedAt : null); - const next: FlowItem[] = [ - { + const next: FlowItem[] = []; + + if (appliedAt) { + next.push({ key: "applied", label: "Applied", at: appliedAt, color: theme.palette.info.main, icon: , - }, - ]; + }); + } if (replyAt) { next.push({ diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index fe3fe7d..4db5f97 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -352,7 +352,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col tags: parseTags(job.tags), actionSignals: actionSignal ? [actionSignal] : [], primaryAction: actionSignal, - appliedDateLabel: new Date(job.dateApplied).toLocaleDateString(), + appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—", isSelected: selectedIdSet.has(job.id), isExpanded: expanded.includes(job.id), }; diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index b06fa1d..64ea6d9 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -6,6 +6,7 @@ import { CardContent, Chip, IconButton, + ListSubheader, Menu, MenuItem, Paper, @@ -19,11 +20,25 @@ import ViewStateNotice from "./ViewStateNotice"; import { JobApplication } from "../types"; import { useI18n } from "../i18n/I18nProvider"; import { useViewResource } from "../hooks/useViewResource"; -import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline"; +import { + GROUP_ENTRY_STATUS, + PIPELINE_GROUPS, + PIPELINE_STATUSES, + PipelineGroup, + PipelineStatus, + groupOf, + normalizeStatus, + statusLabel, + statusTone, +} from "../pipeline"; -const STATUSES = PIPELINE_STATUSES; type Status = PipelineStatus; +// The board shows one column per group rather than one per stage: ten columns is unreadable, and +// the stage detail is preserved on each card's chip. "Other" collects custom statuses, which +// belong to no group, and only appears when something is actually in it. +type ColumnKey = PipelineGroup | "Other"; + const TONE_PALETTE: Record string> = { error: (theme) => theme.palette.error.main, warning: (theme) => theme.palette.warning.main, @@ -89,22 +104,32 @@ export default function KanbanBoard() { const jobs = jobsResource.data; const groups = useMemo(() => { - const map = new Map(); - STATUSES.forEach((s) => map.set(s, [])); + const map = new Map(); + PIPELINE_GROUPS.forEach((g) => map.set(g.key, [])); map.set("Other", []); for (const j of jobs) { - const key = normalizeStatus(j.status); - map.get(key)!.push(j); + map.get(groupOf(j.status) ?? "Other")!.push(j); } map.forEach((arr, k) => { - arr.sort((a, b) => +new Date(b.dateApplied) - +new Date(a.dateApplied)); + // Sort by stage first so a column reads in pipeline order (Saved before Preparing), then by + // recency. Fall back to savedAt: unapplied jobs have no dateApplied, and treating that as + // the epoch would sink them to the bottom. + arr.sort((a, b) => { + const stageDelta = PIPELINE_STATUSES.indexOf(normalizeStatus(a.status) as Status) + - PIPELINE_STATUSES.indexOf(normalizeStatus(b.status) as Status); + if (stageDelta !== 0) return stageDelta; + return +new Date(b.dateApplied ?? b.savedAt) - +new Date(a.dateApplied ?? a.savedAt); + }); map.set(k, arr); }); return map; }, [jobs]); - const onDropTo = async (status: Status) => { + // A group is not a status, so dropping onto a column applies that group's entry stage. Precise + // stages stay on the card menu. + const onDropTo = async (group: PipelineGroup) => { if (!dragJobId) return; + const status = GROUP_ENTRY_STATUS[group]; setDragJobId(null); await api.patch(`/jobapplications/${dragJobId}/status`, { status }); jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j))); @@ -117,6 +142,20 @@ export default function KanbanBoard() { const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? ""); + // "Other" (custom statuses) only earns a column when something is in it, and is not a drop + // target — there is no single custom status to assign. + const columns = useMemo(() => { + const base: { key: ColumnKey; label: string; droppable: boolean }[] = PIPELINE_GROUPS.map((g) => ({ + key: g.key, + label: t(g.labelKey as any), + droppable: true, + })); + if ((groups.get("Other") ?? []).length > 0) { + base.push({ key: "Other", label: t("kanbanGroupOther"), droppable: false }); + } + return base; + }, [groups, t]); + return ( @@ -135,7 +174,7 @@ export default function KanbanBoard() { - {STATUSES.map((status) => { - const c = toneColor(theme, status); - const list = groups.get(status) ?? []; + {columns.map(({ key, label, droppable }) => { + const list = groups.get(key) ?? []; + // Colour the column by its entry stage so the header dot still carries meaning. + const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : theme.palette.grey[500]; return ( e.preventDefault()} - onDrop={() => void onDropTo(status)} + key={key} + onDragOver={(e) => { if (droppable) e.preventDefault(); }} + onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }} sx={{ p: 1.5, borderRadius: 3, @@ -166,7 +206,7 @@ export default function KanbanBoard() { - {statusLabel(t, status)} + {label} { const pill = cardPill(j, t); const tags = parseTags(j.tags); + // The column is a group, so each card names its own stage — grouping collapses + // the columns, not the information. + const cardStatus = normalizeStatus(j.status); + const cardColor = toneColor(theme, cardStatus); return ( + + + + {tags.length > 0 && ( {tags.map((tag) => ( @@ -252,9 +309,11 @@ export default function KanbanBoard() { )} - - {t("kanbanAppliedAgo", { days: j.daysSince })} - + {j.daysSince != null ? ( + + {t("kanbanAppliedAgo", { days: j.daysSince })} + + ) : null} ); @@ -271,14 +330,22 @@ export default function KanbanBoard() { ) : null} + {/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */} { setMenuAnchor(null); setMenuJobId(null); }}> - {(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const) - .filter((s) => s !== currentMenuStatus) - .map((s) => ( - { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}> - {t("jobTableSetStatus", { status: statusLabel(t, s) })} - - ))} + {PIPELINE_GROUPS.flatMap((g) => { + const options = g.statuses.filter((s) => s !== currentMenuStatus); + if (options.length === 0) return []; + return [ + + {t(g.labelKey as any)} + , + ...options.map((s) => ( + { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}> + {t("jobTableSetStatus", { status: statusLabel(t, s) })} + + )), + ]; + })} ); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 41de380..749d312 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -123,12 +123,19 @@ export const translations = { addJobModalJobCreatedUploadFailed: "Job created, but file upload failed.", addJobModalJobCreatedFilesNotAttached: "Job created. Files could not be attached automatically.", addJobModalFailedAddJob: "Failed to add job.", + statusSaved: "Saved", + statusInterested: "Interested", + statusPreparing: "Preparing", statusApplied: "Applied", statusWaiting: "Waiting", statusInterview: "Interview", statusOffer: "Offer", statusRejected: "Rejected", statusGhosted: "Ghosted", + statusWithdrawn: "Withdrawn", + pipelineGroupNotApplied: "Not Applied", + pipelineGroupActive: "Active", + pipelineGroupClosed: "Closed", settingsTitle: "Settings", settingsSubtitle: "Preferences and admin tools.", settingsTabGeneral: "General", @@ -479,8 +486,9 @@ export const translations = { adminUsersDeleteConfirmBody: "Delete this user?", adminUsersDeleteConfirmNamed: "Delete user {name}?", adminUsersPassword: "Password", - kanbanHint: "Drag cards between columns to update status.", + kanbanHint: "Drag cards between columns to move a job forward. Use the card menu to set an exact stage.", kanbanDropHere: "Drop here", + kanbanGroupOther: "Other", kanbanAppliedAgo: "Applied {days}d ago", kanbanFollowUpNow: "Follow up now", kanbanReplyDueIn: "Reply due in {days}d", @@ -1185,12 +1193,19 @@ export const translations = { addJobModalJobCreatedUploadFailed: "Jobben ble opprettet, men filopplasting mislyktes.", addJobModalJobCreatedFilesNotAttached: "Jobben ble opprettet. Filene kunne ikke knyttes automatisk.", addJobModalFailedAddJob: "Kunne ikke legge til jobb.", + statusSaved: "Lagret", + statusInterested: "Interessert", + statusPreparing: "Forbereder", statusApplied: "Søkt", statusWaiting: "Venter", statusInterview: "Intervju", statusOffer: "Tilbud", statusRejected: "Avslått", statusGhosted: "Ghostet", + statusWithdrawn: "Trukket", + pipelineGroupNotApplied: "Ikke søkt", + pipelineGroupActive: "Aktive", + pipelineGroupClosed: "Avsluttet", settingsTitle: "Innstillinger", settingsSubtitle: "Preferanser og adminverktøy.", settingsTabGeneral: "Generelt", @@ -1541,8 +1556,9 @@ export const translations = { adminUsersDeleteConfirmBody: "Slette denne brukeren?", adminUsersDeleteConfirmNamed: "Slette bruker {name}?", adminUsersPassword: "Passord", - kanbanHint: "Dra kort mellom kolonnene for å oppdatere status.", + kanbanHint: "Dra kort mellom kolonnene for å flytte en jobb videre. Bruk kortmenyen for å sette et eksakt trinn.", kanbanDropHere: "Slipp her", + kanbanGroupOther: "Andre", kanbanAppliedAgo: "Søkt for {days}d siden", kanbanFollowUpNow: "Følg opp nå", kanbanReplyDueIn: "Svar forfaller om {days}d", diff --git a/job-tracker-ui/src/kanban-grouped-board.test.tsx b/job-tracker-ui/src/kanban-grouped-board.test.tsx new file mode 100644 index 0000000..a23bb88 --- /dev/null +++ b/job-tracker-ui/src/kanban-grouped-board.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import { api } from './api'; +import { JobApplication } from './types'; + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + patch: jest.fn(() => Promise.resolve({ data: {} })), + }, +})); + +// eslint-disable-next-line import/first +import KanbanBoard from './components/KanbanBoard'; + +const mockedApi = api as jest.Mocked; + +function job(id: number, jobTitle: string, status: string, overrides: Partial = {}): JobApplication { + return { + id, + jobTitle, + company: { id: 1, name: 'Acme' }, + companyId: 1, + status, + // Pre-application stages carry no applied date — that is the whole point of the split. + dateApplied: null, + savedAt: new Date('2026-07-01T00:00:00Z').toISOString(), + daysSince: null, + ...overrides, + } as JobApplication; +} + +function renderBoard() { + return render( + + + + + , + ); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('the board collapses ten stages into the three agreed groups', async () => { + mockedApi.get.mockResolvedValue({ + data: [ + job(1, 'Saved Role', 'Saved'), + job(2, 'Preparing Role', 'Preparing'), + job(3, 'Applied Role', 'Applied', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }), + job(4, 'Offer Role', 'Offer', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }), + job(5, 'Withdrawn Role', 'Withdrawn', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }), + ], + } as any); + + renderBoard(); + + await screen.findByText('Not Applied'); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Closed')).toBeInTheDocument(); + + // Only the three groups — no "Other" column, because no custom statuses are present. + expect(screen.queryByText('Other')).not.toBeInTheDocument(); + + // Every job is placed, and grouping does not hide which stage it is actually in. + expect(screen.getByText('Saved Role')).toBeInTheDocument(); + expect(screen.getByText('Withdrawn Role')).toBeInTheDocument(); + expect(screen.getByText('Withdrawn')).toBeInTheDocument(); + expect(screen.getByText('Preparing')).toBeInTheDocument(); + // Offer groups under Active even though it is a Success category on the backend. + expect(screen.getByText('Offer')).toBeInTheDocument(); +}); + +test('a custom status gets an Other column that is not a drop target', async () => { + mockedApi.get.mockResolvedValue({ + data: [job(1, 'Odd Role', 'Take-home assignment')], + } as any); + + renderBoard(); + + await screen.findByText('Other'); + // Custom statuses survive rather than being coerced into a canonical stage. + expect(screen.getByText('Take-home assignment')).toBeInTheDocument(); +}); + +test('dropping onto a group applies that group entry stage', async () => { + mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any); + + renderBoard(); + + const card = await screen.findByText('Saved Role'); + const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!; + + fireEvent.dragStart(card.closest('.MuiCard-root')!); + fireEvent.dragOver(column); + fireEvent.drop(column); + + // A group is not a status, so a coarse drag applies the group's entry stage. + await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/7/status', { status: 'Applied' })); +}); + +test('the card menu offers precise stages grouped by section', async () => { + mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any); + + renderBoard(); + + await screen.findByText('Saved Role'); + fireEvent.click(screen.getByRole('button', { name: '' }) ?? screen.getAllByRole('button')[0]); + + const menu = await screen.findByRole('menu'); + // Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all. + expect(within(menu).getByText(/Ghosted/)).toBeInTheDocument(); + expect(within(menu).getByText(/Withdrawn/)).toBeInTheDocument(); + // The job's own current stage is not offered as a target. + expect(within(menu).queryByText(/Set status: Saved/)).not.toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/pipeline.test.ts b/job-tracker-ui/src/pipeline.test.ts index 6c6f3f6..93fdf27 100644 --- a/job-tracker-ui/src/pipeline.test.ts +++ b/job-tracker-ui/src/pipeline.test.ts @@ -1,4 +1,13 @@ -import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline'; +import { + GROUP_ENTRY_STATUS, + PIPELINE_GROUPS, + PIPELINE_STATUSES, + groupOf, + isProspect, + normalizeStatus, + statusLabel, + statusTone, +} from './pipeline'; describe('pipeline', () => { test('normalizeStatus canonicalizes casing and synonyms', () => { @@ -32,6 +41,69 @@ describe('pipeline', () => { }); test('canonical stage list is stable and ordered', () => { - expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']); + // Mirrors the backend JobPipeline.Stages. Waiting and Ghosted are retained deliberately: + // Ghosted is where the rules engine parks an unanswered job, and Waiting has its own + // follow-up rule. + expect(PIPELINE_STATUSES).toEqual([ + 'Saved', + 'Interested', + 'Preparing', + 'Applied', + 'Waiting', + 'Interview', + 'Offer', + 'Rejected', + 'Ghosted', + 'Withdrawn', + ]); + }); + + test('normalizeStatus canonicalizes the new stage synonyms', () => { + expect(normalizeStatus('bookmarked')).toBe('Saved'); + expect(normalizeStatus('shortlisted')).toBe('Interested'); + expect(normalizeStatus('drafting')).toBe('Preparing'); + expect(normalizeStatus('withdrew')).toBe('Withdrawn'); + expect(normalizeStatus('cancelled')).toBe('Withdrawn'); + // Opposite directions: the employer declined you vs you pulled out. + expect(normalizeStatus('declined')).toBe('Rejected'); + }); + + test('board groups match the agreed layout', () => { + expect(PIPELINE_GROUPS.map((g) => g.key)).toEqual(['NotApplied', 'Active', 'Closed']); + expect(PIPELINE_GROUPS[0].statuses).toEqual(['Saved', 'Interested', 'Preparing']); + expect(PIPELINE_GROUPS[1].statuses).toEqual(['Applied', 'Waiting', 'Interview', 'Offer']); + expect(PIPELINE_GROUPS[2].statuses).toEqual(['Rejected', 'Ghosted', 'Withdrawn']); + }); + + test('every stage belongs to exactly one group, and groups agree with the backend', () => { + const grouped = PIPELINE_GROUPS.flatMap((g) => g.statuses); + expect(grouped.slice().sort()).toEqual(PIPELINE_STATUSES.slice().sort()); + expect(new Set(grouped).size).toBe(grouped.length); + }); + + test('groupOf and isProspect classify stages', () => { + expect(groupOf('Saved')).toBe('NotApplied'); + expect(groupOf('Offer')).toBe('Active'); + expect(groupOf('Withdrawn')).toBe('Closed'); + expect(groupOf('Take-home assignment')).toBeNull(); + + expect(isProspect('Preparing')).toBe(true); + expect(isProspect('Applied')).toBe(false); + // Custom statuses predate the split and have always counted as applied. + expect(isProspect('Take-home assignment')).toBe(false); + }); + + test('drag entry stages never infer Ghosted or Withdrawn', () => { + // A coarse drag must not claim the rules engine's conclusion (Ghosted) or the user's own + // action (Withdrawn). + expect(GROUP_ENTRY_STATUS).toEqual({ NotApplied: 'Saved', Active: 'Applied', Closed: 'Rejected' }); + }); + + test('statusTone covers every stage', () => { + expect(statusTone('Saved')).toBe('default'); + expect(statusTone('Withdrawn')).toBe('error'); + for (const s of PIPELINE_STATUSES) { + expect(typeof statusTone(s)).toBe('string'); + } }); }); diff --git a/job-tracker-ui/src/pipeline.ts b/job-tracker-ui/src/pipeline.ts index 9e61fc4..1302a1c 100644 --- a/job-tracker-ui/src/pipeline.ts +++ b/job-tracker-ui/src/pipeline.ts @@ -1,22 +1,98 @@ // Single frontend source of truth for the canonical job pipeline. // Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync. +// The backend serves the same shape from GET /jobapplications/pipeline (key/order/category/group). -export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; +/** + * Detailed internal stages, in pipeline order. The board does not show ten columns — it groups + * these via PIPELINE_GROUPS below. + * + * Waiting and Ghosted are retained deliberately: Ghosted is where the rules engine parks a job + * that was never answered (neither Rejected nor Withdrawn), and Waiting carries its own follow-up + * rule and reminder wording. + */ +export const PIPELINE_STATUSES = [ + "Saved", + "Interested", + "Preparing", + "Applied", + "Waiting", + "Interview", + "Offer", + "Rejected", + "Ghosted", + "Withdrawn", +] as const; export type PipelineStatus = (typeof PIPELINE_STATUSES)[number]; +export type PipelineGroup = "NotApplied" | "Active" | "Closed"; + +/** + * How the board collapses stages. A different axis from the backend's analytics "category": + * Offer is category Success there, but groups under Active here because it is still being worked. + */ +export const PIPELINE_GROUPS: { key: PipelineGroup; labelKey: string; statuses: PipelineStatus[] }[] = [ + { key: "NotApplied", labelKey: "pipelineGroupNotApplied", statuses: ["Saved", "Interested", "Preparing"] }, + { key: "Active", labelKey: "pipelineGroupActive", statuses: ["Applied", "Waiting", "Interview", "Offer"] }, + { key: "Closed", labelKey: "pipelineGroupClosed", statuses: ["Rejected", "Ghosted", "Withdrawn"] }, +]; + +const GROUP_BY_STATUS = new Map( + PIPELINE_GROUPS.flatMap((g) => g.statuses.map((s) => [s, g.key] as const)), +); + +/** + * Stage a job lands on when dragged onto a grouped column. A group is not itself a status, so a + * coarse drag has to pick one: the group's entry stage. Precise stages stay available on the + * card menu. + * + * Closed deliberately enters on Rejected rather than Ghosted or Withdrawn: Ghosted is something the + * rules engine concludes, and Withdrawn is a specific claim about the user's own action. Neither + * should be inferred from a drag. + */ +export const GROUP_ENTRY_STATUS: Record = { + NotApplied: "Saved", + Active: "Applied", + Closed: "Rejected", +}; + +/** The board group a status belongs to. Unknown/custom statuses have no group. */ +export function groupOf(status?: string | null): PipelineGroup | null { + const normalized = normalizeStatus(status); + return normalized === "Other" ? null : GROUP_BY_STATUS.get(normalized) ?? null; +} + +/** True for pre-application stages — nothing has been submitted yet. */ +export function isProspect(status?: string | null): boolean { + return groupOf(status) === "NotApplied"; +} + export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default"; // Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map). const ALIASES: Record = { + bookmarked: "Saved", + wishlist: "Saved", + "to apply": "Saved", + shortlisted: "Interested", + considering: "Interested", + "in preparation": "Preparing", + "preparing application": "Preparing", + drafting: "Preparing", interviewing: "Interview", interviews: "Interview", interviewed: "Interview", + "in interview": "Interview", declined: "Rejected", "no response": "Ghosted", "no reply": "Ghosted", pending: "Waiting", + awaiting: "Waiting", + "in progress": "Waiting", "awaiting response": "Waiting", + withdrew: "Withdrawn", + cancelled: "Withdrawn", + canceled: "Withdrawn", }; /** Canonical status for a raw value, or "Other" for unknown/custom statuses. */ @@ -35,6 +111,7 @@ export function statusTone(status?: string | null): StatusTone { case "Offer": return "success"; case "Rejected": + case "Withdrawn": return "error"; case "Waiting": case "Ghosted": @@ -43,18 +120,27 @@ export function statusTone(status?: string | null): StatusTone { return "info"; case "Applied": return "primary"; + // Pre-application stages read as neutral: nothing is in flight, so nothing needs attention. + case "Saved": + case "Interested": + case "Preparing": + return "default"; default: return "default"; } } const LABEL_KEYS: Record = { + Saved: "statusSaved", + Interested: "statusInterested", + Preparing: "statusPreparing", Applied: "statusApplied", Waiting: "statusWaiting", Interview: "statusInterview", Offer: "statusOffer", Rejected: "statusRejected", Ghosted: "statusGhosted", + Withdrawn: "statusWithdrawn", }; /** Localized label for a status, falling back to the raw value for custom statuses. */ diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 9143db6..9955dfa 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -86,7 +86,12 @@ export interface JobApplication { company: Company; companyId?: number; status: string; - dateApplied: string; + // Null while the job sits in a pre-application stage (Saved/Interested/Preparing): nothing has + // been submitted, so there is no applied date. Render "—", never a fabricated date. + dateApplied: string | null; + // When the user captured the job. Always set — use it wherever a job needs a date to sort or + // show regardless of whether it has been applied to. + savedAt: string; location?: string; salary?: string; salaryMin?: number | null; @@ -118,7 +123,8 @@ export interface JobApplication { hasPortfolio?: boolean; hasOtherAttachment?: boolean; - daysSince: number; + // Null when dateApplied is null — there is no elapsed time to report before applying. + daysSince: number | null; isDeleted?: boolean; deletedAt?: string; needsFollowUp?: boolean; diff --git a/job-tracker-ui/src/views/CareerWorkspacePage.tsx b/job-tracker-ui/src/views/CareerWorkspacePage.tsx index bd7a16e..98c249f 100644 --- a/job-tracker-ui/src/views/CareerWorkspacePage.tsx +++ b/job-tracker-ui/src/views/CareerWorkspacePage.tsx @@ -1,10 +1,13 @@ -import React from "react"; +import React, { useState } from "react"; -import { Alert, Box, Paper, Typography } from "@mui/material"; +import { Alert, Box, Paper, Tab, Tabs, Typography } from "@mui/material"; import ProfilePage from "./ProfilePage"; export default function CareerWorkspacePage() { + const [tab, setTab] = useState<"master" | "builder">("master"); + const [hasMasterCv, setHasMasterCv] = useState(false); + return ( @@ -16,7 +19,18 @@ export default function CareerWorkspacePage() { Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it. - + + setTab(value)} variant="scrollable" allowScrollButtonsMobile sx={{ px: 1.5, pt: 1 }}> + + + + {!hasMasterCv ? + Create your Master CV first. Upload an existing CV or add your career history manually; the builder will unlock when the profile has content. + : null} + + + + ); } diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 3d204ce..4ab3a57 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -226,7 +226,15 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) ); } -export default function ProfilePage({ careerOnly = false }: { careerOnly?: boolean }) { +export default function ProfilePage({ + careerOnly = false, + careerView = "master", + onMasterCvAvailabilityChange, +}: { + careerOnly?: boolean; + careerView?: "master" | "builder"; + onMasterCvAvailabilityChange?: (hasMasterCv: boolean) => void; +}) { const { toast } = useToast(); const { t } = useI18n(); const cvInputRef = useRef(null); diff --git a/job-tracker-ui/src/workflow-trust-signals.test.tsx b/job-tracker-ui/src/workflow-trust-signals.test.tsx index bc80dff..36df808 100644 --- a/job-tracker-ui/src/workflow-trust-signals.test.tsx +++ b/job-tracker-ui/src/workflow-trust-signals.test.tsx @@ -22,6 +22,7 @@ function buildJob(overrides: Partial): JobApplication { companyId: 1, status: 'Waiting', dateApplied: new Date('2026-03-01T00:00:00Z').toISOString(), + savedAt: new Date('2026-03-01T00:00:00Z').toISOString(), location: 'Oslo', salary: undefined, nextAction: undefined, diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index 054e1d8..30fd10a 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -1,4 +1,5 @@ -from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi import FastAPI, File, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from transformers import AutoTokenizer, AutoModelForSeq2SeqLM from cachetools import TTLCache @@ -7,6 +8,7 @@ from pypdf import PdfReader from docx import Document import fitz import hashlib +import hmac import io import json import os @@ -18,6 +20,33 @@ from urllib.error import URLError, HTTPError app = FastAPI(title="Local AI Service") +# Shared secret for backend -> ai-service calls. This service has no user auth and can +# generate against a paid provider (gemini/groq), so an unauthenticated caller on the +# shared docker network could drain the API key. The port is no longer published to the +# host (compose uses `expose`), and this header is the second layer. +# +# Unset => open, so local dev and the test suite work keyless. Production cannot reach +# that state: docker-compose declares AI_SERVICE_TOKEN with `:?` so the stack refuses to +# start without it. +AI_SERVICE_TOKEN = os.getenv("AI_SERVICE_TOKEN", "").strip() +AI_SERVICE_TOKEN_HEADER = "X-Ai-Service-Token" +# /health stays open: the backend probe and the compose healthcheck both call it, and it +# exposes no user data and no generation path. +AI_SERVICE_OPEN_PATHS = {"/health"} + + +@app.middleware("http") +async def require_service_token(request: Request, call_next): + if AI_SERVICE_TOKEN and request.url.path not in AI_SERVICE_OPEN_PATHS: + supplied = request.headers.get(AI_SERVICE_TOKEN_HEADER, "") + # compare_digest to avoid leaking the token through response timing. + if not hmac.compare_digest(supplied, AI_SERVICE_TOKEN): + return JSONResponse( + {"detail": "Invalid or missing service token."}, + status_code=401, + ) + return await call_next(request) + MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MAX_INPUT_CHARS = 20000 MAX_CONTEXT_CHARS = 2200 diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index 100d3eb..e8e6a5f 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -11,12 +11,17 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None): +def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, service_token=None): if skip_model_load: monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1") else: monkeypatch.delenv("AI_SERVICE_SKIP_MODEL_LOAD", raising=False) monkeypatch.delenv("AI_SERVICE_EAGER_MODEL_LOAD", raising=False) + # Default to keyless so the existing suite is unaffected by a token in the dev shell. + if service_token is None: + monkeypatch.delenv("AI_SERVICE_TOKEN", raising=False) + else: + monkeypatch.setenv("AI_SERVICE_TOKEN", service_token) if ollama_model is None: monkeypatch.delenv("OLLAMA_MODEL", raising=False) else: @@ -245,3 +250,57 @@ def test_health_reports_active_provider(monkeypatch): assert payload["ai_provider"] == "gemini" assert payload["ai_provider_configured"] is True + + +def test_service_token_rejects_calls_without_the_header(monkeypatch): + module = load_app_module(monkeypatch, service_token="s3cret") + client = TestClient(module.app) + + response = client.post("/summarize", json={"text": "Platform engineering role."}) + + assert response.status_code == 401 + assert "token" in response.json()["detail"].lower() + + +def test_service_token_rejects_a_wrong_header(monkeypatch): + module = load_app_module(monkeypatch, service_token="s3cret") + client = TestClient(module.app) + + response = client.post( + "/summarize", + json={"text": "Platform engineering role."}, + headers={"X-Ai-Service-Token": "wrong"}, + ) + + assert response.status_code == 401 + + +def test_service_token_allows_the_correct_header(monkeypatch): + module = load_app_module(monkeypatch, service_token="s3cret") + client = TestClient(module.app) + + response = client.post( + "/summarize", + json={"text": "Platform engineering role."}, + headers={"X-Ai-Service-Token": "s3cret"}, + ) + + # 503 = passed the token gate and reached the handler, which is model-disabled here. + assert response.status_code == 503 + + +def test_health_stays_open_so_probes_and_healthchecks_work(monkeypatch): + module = load_app_module(monkeypatch, service_token="s3cret") + client = TestClient(module.app) + + assert client.get("/health").status_code == 200 + + +def test_endpoints_stay_open_when_no_token_is_configured(monkeypatch): + module = load_app_module(monkeypatch) + client = TestClient(module.app) + + # Keyless local dev: reaches the handler (503 model-disabled), not a 401. + response = client.post("/summarize", json={"text": "Platform engineering role."}) + + assert response.status_code == 503