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
@@ -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");