feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history

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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
+3
View File
@@ -18,6 +18,9 @@ MICROSOFT_TENANT_ID=
# Optional. If omitted, the backend uses https://<your-domain>/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
+6
View File
@@ -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/
+28
View File
@@ -14,6 +14,7 @@ namespace JobTrackerApi.Data
}
public DbSet<Company> Companies => Set<Company>();
public DbSet<Job> Jobs => Set<Job>();
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
@@ -42,6 +43,33 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<JobApplication>()
.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<Job>()
.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<Job>()
.HasOne(j => j.Company)
.WithMany()
.HasForeignKey(j => j.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Job>()
.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<JobApplication>()
.HasOne(j => j.Job)
.WithMany(o => o.Applications)
.HasForeignKey(j => j.JobId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<UserRuleSettings>()
.HasKey(x => x.OwnerUserId);
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<JobApplication> 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<ISummarizerService>(), Mock.Of<IAppEmailSender>(), TestHostFactory.CreateUserManager().Object, NullLogger<JobApplicationsController>.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();
}
+170
View File
@@ -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);
}
}
@@ -0,0 +1,82 @@
using System;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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)),
@@ -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<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime? DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
public sealed record FocusPlanDto(
@@ -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:
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
[HttpGet("pipeline")]
public ActionResult<IEnumerable<PipelineStageDto>> 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<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
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,
});
}
/// <summary>
/// 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);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <summary>
/// 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.
/// </summary>
public partial class AddJobEntityAndProspectStages : Migration
{
/// <inheritdoc />
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<DateTime>(
name: "DateApplied",
table: "JobApplications",
type: "TEXT",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "TEXT");
migrationBuilder.AddColumn<int>(
name: "JobId",
table: "JobApplications",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: true),
CompanyId = table.Column<int>(type: "INTEGER", nullable: false),
JobTitle = table.Column<string>(type: "TEXT", nullable: false),
Location = table.Column<string>(type: "TEXT", nullable: true),
JobUrl = table.Column<string>(type: "TEXT", nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
TranslatedDescription = table.Column<string>(type: "TEXT", nullable: true),
DescriptionLanguage = table.Column<string>(type: "TEXT", nullable: true),
ShortSummary = table.Column<string>(type: "TEXT", nullable: true),
Tags = table.Column<string>(type: "TEXT", nullable: true),
Deadline = table.Column<DateTime>(type: "TEXT", nullable: true),
Salary = table.Column<string>(type: "TEXT", nullable: true),
SalaryMin = table.Column<decimal>(type: "TEXT", nullable: true),
SalaryMax = table.Column<decimal>(type: "TEXT", nullable: true),
SalaryCurrency = table.Column<string>(type: "TEXT", nullable: true),
SalaryPeriod = table.Column<string>(type: "TEXT", nullable: true),
Source = table.Column<string>(type: "TEXT", nullable: true),
CountryCode = table.Column<string>(type: "TEXT", nullable: true),
SavedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
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<DateTime>(
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");
}
}
}
@@ -72,6 +72,15 @@ namespace JobTrackerApi.Migrations
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("MicrosoftEmail")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MicrosoftLinkedAt")
.HasColumnType("TEXT");
b.Property<string>("MicrosoftSubject")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
@@ -98,6 +107,15 @@ namespace JobTrackerApi.Migrations
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TotpPendingSecretEncrypted")
.HasColumnType("TEXT");
b.Property<string>("TotpSecretEncrypted")
.HasColumnType("TEXT");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CountryCode")
.HasColumnType("TEXT");
b.Property<DateTime?>("Deadline")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("DescriptionLanguage")
.HasColumnType("TEXT");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.HasColumnType("TEXT");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("SalaryCurrency")
.HasColumnType("TEXT");
b.Property<decimal?>("SalaryMax")
.HasColumnType("TEXT");
b.Property<decimal?>("SalaryMin")
.HasColumnType("TEXT");
b.Property<string>("SalaryPeriod")
.HasColumnType("TEXT");
b.Property<DateTime>("SavedAt")
.HasColumnType("TEXT");
b.Property<string>("ShortSummary")
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.Property<string>("Tags")
.HasColumnType("TEXT");
b.Property<string>("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<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
b.Property<DateTime?>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("Deadline")
@@ -563,6 +654,9 @@ namespace JobTrackerApi.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<int?>("JobId")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
@@ -609,6 +703,9 @@ namespace JobTrackerApi.Migrations
b.Property<string>("SalaryPeriod")
.HasColumnType("TEXT");
b.Property<DateTime>("SavedAt")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("DeviceLabel")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LastSeenAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("TrustedDevices");
});
modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CodeHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UsedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("DeviceLabel")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LastSeenAtUtc")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RevokedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("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");
+7
View File
@@ -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();
+12 -5
View File
@@ -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<DateTime>()
: 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
@@ -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[]
{
+109 -7
View File
@@ -2,12 +2,32 @@ namespace JobTrackerApi.Services
{
public enum PipelineCategory
{
/// <summary>
/// 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.
/// </summary>
Prospect,
Active,
Success,
Closed,
}
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
/// <summary>
/// How stages collapse on the board. Deliberately NOT the same axis as
/// <see cref="PipelineCategory"/>: 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.
/// </summary>
public enum PipelineGroup
{
NotApplied,
Active,
Closed,
}
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category, PipelineGroup Group);
/// <summary>
/// Canonical job-application pipeline: the single source of truth for the ordered set of
@@ -17,24 +37,68 @@ namespace JobTrackerApi.Services
/// </summary>
public static class JobPipeline
{
/// <summary>
/// Fallback for an empty status on the legacy create path, which historically meant
/// "already applied". New pre-application flows should pass <see cref="SavedStatus"/>
/// explicitly rather than relying on this.
/// </summary>
public const string DefaultStatus = "Applied";
/// <summary>Entry stage for a job captured before the user has applied.</summary>
public const string SavedStatus = "Saved";
/// <summary>
/// The detailed internal stages. The board groups these (see <see cref="PipelineGroup"/>)
/// 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.
/// </summary>
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
{
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),
};
/// <summary>Stage keys in a group, in pipeline order.</summary>
public static IReadOnlyList<string> StagesInGroup(PipelineGroup group)
=> Stages.Where(s => s.Group == group).OrderBy(s => s.Order).Select(s => s.Key).ToList();
/// <summary>True when the status is a pre-application stage (nothing submitted yet).</summary>
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<string, string> 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<string, string> 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",
};
/// <summary>
@@ -65,6 +134,39 @@ namespace JobTrackerApi.Services
public static bool IsCanonical(string? status)
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
/// <summary>
/// Enforces the invariant "DateApplied is set if and only if the job has left the
/// pre-application stages". Call after any write to <see cref="JobApplication.Status"/>;
/// 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 <see cref="Models.JobEvent"/> (Type = <see cref="AppliedDateClearedEvent"/>) so
/// the application activity survives the clear — this method has no DbContext, so it
/// reports what it did rather than recording it.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public const string AppliedDateClearedEvent = "AppliedDateCleared";
public static int OrderOf(string? status)
{
var normalized = Normalize(status);
+11 -2
View File
@@ -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);
+57
View File
@@ -0,0 +1,57 @@
using System;
namespace JobTrackerApi.Models
{
/// <summary>
/// A job opportunity — the role as the employer posted it, independent of whether the user
/// has applied. Separated from <see cref="JobApplication"/> (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:
/// <see cref="JobApplication"/> 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.
/// </summary>
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"
/// <summary>When the user first captured this opportunity.</summary>
public DateTime SavedAt { get; set; } = DateTime.UtcNow;
public List<JobApplication> Applications { get; set; } = new();
}
}
+27 -2
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public DateTime? DateApplied { get; set; }
/// <summary>When the user first captured this job. Always set.</summary>
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<Attachment> Attachments { get; set; } = new();
public List<JobEvent> Events { get; set; } = new();
public int DaysSince => ((ResponseReceived ? (ResponseDate ?? DateTime.UtcNow) : DateTime.UtcNow) - DateApplied.ToUniversalTime()).Days;
/// <summary>
/// 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".
/// </summary>
public int? DaysSince => DateApplied is null
? null
: ((ResponseReceived ? (ResponseDate ?? DateTime.UtcNow) : DateTime.UtcNow) - DateApplied.Value.ToUniversalTime()).Days;
}
}
+38 -4
View File
@@ -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://<host-ip>: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
@@ -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) : "");
@@ -7,7 +7,9 @@ import { Company, JobApplication } from "../types";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type ImportJob = Omit<JobApplication, "id" | "company"> & {
// 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<JobApplication, "id" | "company" | "savedAt"> & {
company: Pick<Company, "name" | "location" | "source">;
};
@@ -723,8 +723,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Typography sx={{ color: "text.secondary" }}>{t("jobDetailsStrategySnapshotEmpty")}</Typography>
</Box>
)}
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job?.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsSalary")}</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsNextAction")}</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
+9 -5
View File
@@ -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: <WorkOutlineIcon fontSize="small" />,
},
];
});
}
if (replyAt) {
next.push({
+1 -1
View File
@@ -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),
};
+94 -27
View File
@@ -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, (theme: any) => 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<string, JobApplication[]>();
STATUSES.forEach((s) => map.set(s, []));
const map = new Map<ColumnKey, JobApplication[]>();
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 (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
@@ -135,7 +174,7 @@ export default function KanbanBoard() {
<Box
sx={{
display: { xs: "flex", md: "grid" },
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
gridTemplateColumns: { md: "repeat(3, 1fr)" },
gap: 2,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
@@ -144,14 +183,15 @@ export default function KanbanBoard() {
"-webkit-overflow-scrolling": "touch",
}}
>
{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 (
<Paper
key={status}
onDragOver={(e) => 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() {
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{statusLabel(t, status)}
{label}
</Typography>
</Box>
<Box
@@ -189,6 +229,10 @@ export default function KanbanBoard() {
{list.map((j) => {
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 (
<Card
key={j.id}
@@ -198,7 +242,7 @@ export default function KanbanBoard() {
sx={{
cursor: "grab",
borderRadius: 2.5,
borderLeft: `4px solid ${c}`,
borderLeft: `4px solid ${cardColor}`,
transition: "box-shadow .15s, transform .15s",
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
"&:active": { cursor: "grabbing" },
@@ -224,6 +268,19 @@ export default function KanbanBoard() {
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
</Typography>
<Box sx={{ mt: 0.75 }}>
<Chip
size="small"
label={statusLabel(t, j.status)}
sx={{
height: 22,
fontWeight: 700,
backgroundColor: alpha(cardColor, 0.14),
color: cardColor,
}}
/>
</Box>
{tags.length > 0 && (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.75 }}>
{tags.map((tag) => (
@@ -252,9 +309,11 @@ export default function KanbanBoard() {
</Box>
)}
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
{t("kanbanAppliedAgo", { days: j.daysSince })}
</Typography>
{j.daysSince != null ? (
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
{t("kanbanAppliedAgo", { days: j.daysSince })}
</Typography>
) : null}
</CardContent>
</Card>
);
@@ -271,14 +330,22 @@ export default function KanbanBoard() {
</Box>
) : null}
{/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */}
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
.filter((s) => s !== currentMenuStatus)
.map((s) => (
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
</MenuItem>
))}
{PIPELINE_GROUPS.flatMap((g) => {
const options = g.statuses.filter((s) => s !== currentMenuStatus);
if (options.length === 0) return [];
return [
<ListSubheader key={`${g.key}-header`} sx={{ lineHeight: "32px", backgroundColor: "transparent" }}>
{t(g.labelKey as any)}
</ListSubheader>,
...options.map((s) => (
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
</MenuItem>
)),
];
})}
</Menu>
</Box>
);
+18 -2
View File
@@ -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",
@@ -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<typeof api>;
function job(id: number, jobTitle: string, status: string, overrides: Partial<JobApplication> = {}): 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(
<ToastProvider>
<I18nProvider>
<KanbanBoard />
</I18nProvider>
</ToastProvider>,
);
}
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();
});
+74 -2
View File
@@ -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');
}
});
});
+87 -1
View File
@@ -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<PipelineStatus, PipelineGroup>(
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<PipelineGroup, PipelineStatus> = {
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<string, PipelineStatus> = {
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<PipelineStatus, string> = {
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. */
+8 -2
View File
@@ -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;
@@ -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 (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
@@ -16,7 +19,18 @@ export default function CareerWorkspacePage() {
<Alert severity="info" sx={{ borderRadius: 3 }}>
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
</Alert>
<ProfilePage careerOnly />
<Paper sx={{ borderRadius: 4, overflow: "hidden", boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Tabs value={tab} onChange={(_, value) => setTab(value)} variant="scrollable" allowScrollButtonsMobile sx={{ px: 1.5, pt: 1 }}>
<Tab value="master" label="Master CV" />
<Tab value="builder" label="CV Builder" disabled={!hasMasterCv} />
</Tabs>
{!hasMasterCv ? <Alert severity="info" sx={{ mx: 2.5, mb: 0, borderRadius: 3 }}>
Create your Master CV first. Upload an existing CV or add your career history manually; the builder will unlock when the profile has content.
</Alert> : null}
<Box sx={{ p: { xs: 1.5, md: 2.5 } }}>
<ProfilePage careerOnly careerView={tab} onMasterCvAvailabilityChange={setHasMasterCv} />
</Box>
</Paper>
</Box>
);
}
+9 -1
View File
@@ -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<HTMLInputElement | null>(null);
@@ -22,6 +22,7 @@ function buildJob(overrides: Partial<JobApplication>): 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,
+30 -1
View File
@@ -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
+60 -1
View File
@@ -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