feat(career): structured career profile foundation
CI and Deploy / test (push) Failing after 1m55s
CI and Deploy / deploy (push) Has been skipped

Phase 3, schema layer. Relational children of CareerProfile — the editable master
career profile. See docs/architecture/career-profile-model.md.

- New entities (Models/CareerEntities.cs): CareerExperience, CareerEducation,
  CareerSkill, CareerProject, CareerCertification, CareerLanguage. Each carries
  OwnerUserId (tenant filter), a stable ItemKey (carried from the blob so future
  CV variants can reference items), and SortOrder. List fields persist as JSON
  string columns via [NotMapped] accessors — plain TEXT, reconciler-friendly.
- CareerProfile gains typed child collections + a LongTailJson column (contact,
  summary, interests, achievements, orgs, pubs, courses, custom sections,
  metadata). ProfileJson becomes a derived projection for legacy read paths.
- DbContext: DbSets + tenant query filters + ordered indexes; FK/cascade by
  convention via the typed collections.
- Migration hand-edited to add only the 6 new tables + LongTailJson; the
  scaffolder re-emitted four reconciler-owned tables (AiWorkspaceNotes,
  CareerProfiles, InterviewPrepNotes, CareerProfileVersions) which were stripped.
  The regenerated snapshot now includes them, closing the drift. Verified against
  a copy of the real dev DB: applies cleanly, no data loss.

Long tail (achievements/orgs/pubs/courses) starts as JSON; promotable to
relational later without a source-of-truth change. Source-of-truth flip stays
deferred; the blob is kept as a derived projection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 00:34:33 +02:00
parent 9c8644e9f9
commit 3a4c8fbc10
6 changed files with 3028 additions and 0 deletions
@@ -17,6 +17,44 @@ namespace JobTrackerApi.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.14");
modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AttachmentContextSignature")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("GeneratedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("NoteType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.HasIndex("OwnerUserId", "JobApplicationId", "NoteType")
.IsUnique();
b.ToTable("AiWorkspaceNotes");
});
modelBuilder.Entity("JobTrackerApi.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
@@ -175,6 +213,381 @@ namespace JobTrackerApi.Migrations
b.ToTable("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Date")
.HasColumnType("TEXT");
b.Property<string>("DateNormalized")
.HasColumnType("TEXT");
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Issuer")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder");
b.ToTable("CareerCertifications");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("End")
.HasColumnType("TEXT");
b.Property<string>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Institution")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Qualification")
.HasColumnType("TEXT");
b.Property<string>("QualificationLevel")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Start")
.HasColumnType("TEXT");
b.Property<string>("StartDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder");
b.ToTable("CareerEducations");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BulletsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Company")
.HasColumnType("TEXT");
b.Property<string>("End")
.HasColumnType("TEXT");
b.Property<string>("EndDate")
.HasColumnType("TEXT");
b.Property<bool>("IsCurrent")
.HasColumnType("INTEGER");
b.Property<string>("ItemKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkillsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Start")
.HasColumnType("TEXT");
b.Property<string>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder");
b.ToTable("CareerExperiences");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<string>("ItemKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Level")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder");
b.ToTable("CareerLanguages");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("LongTailJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ProfileJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OwnerUserId")
.IsUnique();
b.ToTable("CareerProfiles");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ProfileJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "Version");
b.ToTable("CareerProfileVersions");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BulletsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<string>("End")
.HasColumnType("TEXT");
b.Property<string>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("LinksJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.HasColumnType("TEXT");
b.Property<string>("SkillsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Start")
.HasColumnType("TEXT");
b.Property<string>("StartDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder");
b.ToTable("CareerProjects");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareerProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Proficiency")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CareerProfileId");
b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder");
b.ToTable("CareerSkills");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
@@ -533,6 +946,52 @@ namespace JobTrackerApi.Migrations
b.ToTable("ImapConnections");
});
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AttachmentContextSignature")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("GeneratedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("LikelyQuestionsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Summary")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TalkingPointsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("WeakSpotsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.HasIndex("OwnerUserId", "JobApplicationId")
.IsUnique();
b.ToTable("InterviewPrepNotes");
});
modelBuilder.Entity("JobTrackerApi.Models.Job", b =>
{
b.Property<int>("Id")
@@ -1224,6 +1683,17 @@ namespace JobTrackerApi.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany()
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
@@ -1235,6 +1705,83 @@ namespace JobTrackerApi.Migrations
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany("Certifications")
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany("Education")
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany("Experiences")
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany("Languages")
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany()
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany("Projects")
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b =>
{
b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile")
.WithMany("Skills")
.HasForeignKey("CareerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareerProfile");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
@@ -1256,6 +1803,17 @@ namespace JobTrackerApi.Migrations
b.Navigation("Artifact");
});
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany()
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Job", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
@@ -1358,6 +1916,21 @@ namespace JobTrackerApi.Migrations
.IsRequired();
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b =>
{
b.Navigation("Certifications");
b.Navigation("Education");
b.Navigation("Experiences");
b.Navigation("Languages");
b.Navigation("Projects");
b.Navigation("Skills");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");