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:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+1387
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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[]
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user