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

Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.

Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
  JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
  tables the scaffolder re-emitted; verified against the real dev DB.

Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).

DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.

AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.

Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.

Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
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");