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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user