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
+29
View File
@@ -34,6 +34,12 @@ namespace JobTrackerApi.Data
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
public DbSet<CareerExperience> CareerExperiences => Set<CareerExperience>();
public DbSet<CareerEducation> CareerEducations => Set<CareerEducation>();
public DbSet<CareerSkill> CareerSkills => Set<CareerSkill>();
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
@@ -242,6 +248,17 @@ namespace JobTrackerApi.Data
.HasForeignKey(x => x.CareerProfileId)
.OnDelete(DeleteBehavior.Cascade);
// Phase 3: relational children of CareerProfile (the editable master profile).
// docs/architecture/career-profile-model.md. Same deny-on-null tenant filter as everything
// else; cascade-delete with the parent; indexed by (OwnerUserId, CareerProfileId, SortOrder)
// for the ordered per-profile reads the /career editor does.
ConfigureCareerChild<CareerExperience>(modelBuilder);
ConfigureCareerChild<CareerEducation>(modelBuilder);
ConfigureCareerChild<CareerSkill>(modelBuilder);
ConfigureCareerChild<CareerProject>(modelBuilder);
ConfigureCareerChild<CareerCertification>(modelBuilder);
ConfigureCareerChild<CareerLanguage>(modelBuilder);
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
modelBuilder.Entity<InterviewPrepNote>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
@@ -272,5 +289,17 @@ namespace JobTrackerApi.Data
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
}
// Common config for CareerProfile's relational children. The 1:many FK + cascade delete is
// wired by convention (each child's CareerProfileId + CareerProfile nav match the typed
// collection on CareerProfile); this adds the tenant query filter and the ordered read index.
private void ConfigureCareerChild<T>(ModelBuilder modelBuilder) where T : Models.CareerChildEntity
{
modelBuilder.Entity<T>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<T>()
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.SortOrder });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,283 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <summary>
/// Phase 3: relational children of CareerProfile (Experience/Education/Skill/Project/
/// Certification/Language) + the LongTailJson column on CareerProfiles. See
/// docs/architecture/career-profile-model.md.
///
/// HAND-EDITED after scaffolding. `dotnet ef migrations add` also re-emitted CreateTable for
/// AiWorkspaceNotes / CareerProfiles / InterviewPrepNotes / CareerProfileVersions — all of which
/// already exist in every real database (provisioned by the F1/Phase-0 reconciler in
/// StartupInitializationExtensions, which the prior ModelSnapshot did not know). Those creates
/// were removed so the migration applies cleanly on existing databases; the regenerated
/// snapshot now includes them, closing the drift. Only the six genuinely-new child tables and
/// the new LongTailJson column remain here.
/// </summary>
public partial class AddCareerProfileRelationalChildren : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// New editable long-tail JSON on the existing CareerProfiles table.
migrationBuilder.AddColumn<string>(
name: "LongTailJson",
table: "CareerProfiles",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "CareerCertifications",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Issuer = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Date = table.Column<string>(type: "TEXT", nullable: true),
DateNormalized = table.Column<string>(type: "TEXT", nullable: true),
DetailsJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerCertifications", x => x.Id);
table.ForeignKey(
name: "FK_CareerCertifications_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerEducations",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Qualification = table.Column<string>(type: "TEXT", nullable: true),
QualificationLevel = table.Column<string>(type: "TEXT", nullable: true),
Institution = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Start = table.Column<string>(type: "TEXT", nullable: true),
End = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<string>(type: "TEXT", nullable: true),
EndDate = table.Column<string>(type: "TEXT", nullable: true),
DetailsJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerEducations", x => x.Id);
table.ForeignKey(
name: "FK_CareerEducations_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerExperiences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", nullable: true),
Company = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Start = table.Column<string>(type: "TEXT", nullable: true),
End = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<string>(type: "TEXT", nullable: true),
EndDate = table.Column<string>(type: "TEXT", nullable: true),
IsCurrent = table.Column<bool>(type: "INTEGER", nullable: false),
BulletsJson = table.Column<string>(type: "TEXT", nullable: false),
SkillsJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerExperiences", x => x.Id);
table.ForeignKey(
name: "FK_CareerExperiences_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerLanguages",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Level = table.Column<string>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerLanguages", x => x.Id);
table.ForeignKey(
name: "FK_CareerLanguages_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerProjects",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Role = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Start = table.Column<string>(type: "TEXT", nullable: true),
End = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<string>(type: "TEXT", nullable: true),
EndDate = table.Column<string>(type: "TEXT", nullable: true),
BulletsJson = table.Column<string>(type: "TEXT", nullable: false),
SkillsJson = table.Column<string>(type: "TEXT", nullable: false),
LinksJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerProjects", x => x.Id);
table.ForeignKey(
name: "FK_CareerProjects_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerSkills",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Category = table.Column<string>(type: "TEXT", nullable: true),
Proficiency = table.Column<string>(type: "TEXT", nullable: true),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerSkills", x => x.Id);
table.ForeignKey(
name: "FK_CareerSkills_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CareerCertifications_CareerProfileId",
table: "CareerCertifications",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerCertifications",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerEducations_CareerProfileId",
table: "CareerEducations",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerEducations",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerExperiences_CareerProfileId",
table: "CareerExperiences",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerExperiences",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerLanguages_CareerProfileId",
table: "CareerLanguages",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerLanguages",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerProjects_CareerProfileId",
table: "CareerProjects",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerProjects",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerSkills_CareerProfileId",
table: "CareerSkills",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerSkills",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "CareerCertifications");
migrationBuilder.DropTable(name: "CareerEducations");
migrationBuilder.DropTable(name: "CareerExperiences");
migrationBuilder.DropTable(name: "CareerLanguages");
migrationBuilder.DropTable(name: "CareerProjects");
migrationBuilder.DropTable(name: "CareerSkills");
migrationBuilder.DropColumn(
name: "LongTailJson",
table: "CareerProfiles");
}
}
}
@@ -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");
+164
View File
@@ -0,0 +1,164 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace JobTrackerApi.Models;
// Phase 3: relational children of CareerProfile — the editable master career profile.
// See docs/architecture/career-profile-model.md.
//
// Design notes shared by all child entities:
// - OwnerUserId is denormalized so the tenant global query filter applies directly (same pattern
// as every other owned entity).
// - ItemKey carries the stable item id from the StructuredCvProfile blob, so a row keeps its
// identity across imports/edits and future CV variants can reference "this experience".
// - SortOrder makes ordering explicit (the blob relied on array position).
// - List fields persist as JSON string columns (plain TEXT — reconciler-friendly on both SQLite
// and MySQL) exposed via [NotMapped] accessors. Children are replaced wholesale on save, so
// fine-grained change tracking of the lists is not needed.
/// <summary>Base fields every CareerProfile child shares.</summary>
public abstract class CareerChildEntity
{
public int Id { get; set; }
public int CareerProfileId { get; set; }
public CareerProfile? CareerProfile { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
/// <summary>Stable identity carried from StructuredCvProfile so references survive edits.</summary>
public string ItemKey { get; set; } = string.Empty;
public int SortOrder { get; set; }
}
internal static class CareerJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
public static List<string> ReadList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<string>>(json, Options) ?? new(); }
catch { return new(); }
}
public static string WriteList(List<string> items) => JsonSerializer.Serialize(items ?? new(), Options);
}
public sealed class CareerExperience : CareerChildEntity
{
public string? Title { get; set; }
public string? Company { get; set; }
public string? Location { get; set; }
// Free-text period kept alongside best-effort "YYYY-MM" normalization; neither replaces the other.
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public bool IsCurrent { get; set; }
public string BulletsJson { get; set; } = "[]";
public string SkillsJson { get; set; } = "[]";
[NotMapped]
public List<string> Bullets
{
get => CareerJson.ReadList(BulletsJson);
set => BulletsJson = CareerJson.WriteList(value);
}
[NotMapped]
public List<string> Skills
{
get => CareerJson.ReadList(SkillsJson);
set => SkillsJson = CareerJson.WriteList(value);
}
}
public sealed class CareerEducation : CareerChildEntity
{
public string? Qualification { get; set; }
public string? QualificationLevel { get; set; }
public string? Institution { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public string DetailsJson { get; set; } = "[]";
[NotMapped]
public List<string> Details
{
get => CareerJson.ReadList(DetailsJson);
set => DetailsJson = CareerJson.WriteList(value);
}
}
public sealed class CareerSkill : CareerChildEntity
{
public string? Name { get; set; }
public string? Category { get; set; }
public string? Proficiency { get; set; }
}
public sealed class CareerProject : CareerChildEntity
{
public string? Name { get; set; }
public string? Role { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public string BulletsJson { get; set; } = "[]";
public string SkillsJson { get; set; } = "[]";
public string LinksJson { get; set; } = "[]";
[NotMapped]
public List<string> Bullets
{
get => CareerJson.ReadList(BulletsJson);
set => BulletsJson = CareerJson.WriteList(value);
}
[NotMapped]
public List<string> Skills
{
get => CareerJson.ReadList(SkillsJson);
set => SkillsJson = CareerJson.WriteList(value);
}
[NotMapped]
public List<string> Links
{
get => CareerJson.ReadList(LinksJson);
set => LinksJson = CareerJson.WriteList(value);
}
}
public sealed class CareerCertification : CareerChildEntity
{
public string? Name { get; set; }
public string? Issuer { get; set; }
public string? Location { get; set; }
public string? Date { get; set; }
public string? DateNormalized { get; set; }
public string DetailsJson { get; set; } = "[]";
[NotMapped]
public List<string> Details
{
get => CareerJson.ReadList(DetailsJson);
set => DetailsJson = CareerJson.WriteList(value);
}
}
public sealed class CareerLanguage : CareerChildEntity
{
public string? Name { get; set; }
public string? Level { get; set; }
public string? Notes { get; set; }
}
+19
View File
@@ -8,10 +8,29 @@ public sealed class CareerProfile
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
// Phase 3: ProfileJson is now a DERIVED projection of the relational children + LongTailJson,
// serialized into the StructuredCvProfile shape and kept in sync on every save so the legacy
// read paths (CV rendering, tailoring, match-score) keep working unchanged. It is no longer an
// independently editable source. See docs/architecture/career-profile-model.md §4.
public string ProfileJson { get; set; } = string.Empty;
// The editable long tail that is not worth a relational table: contact, summary, interests,
// achievements, organisations, publications, courses, custom/other sections, and AI-extraction
// metadata. A JSON object (CareerLongTail). The relational children (Experience/Education/Skill/
// Project/Certification/Language) hold the rest.
public string LongTailJson { get; set; } = string.Empty;
public int Version { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public List<CareerExperience> Experiences { get; set; } = new();
public List<CareerEducation> Education { get; set; } = new();
public List<CareerSkill> Skills { get; set; } = new();
public List<CareerProject> Projects { get; set; } = new();
public List<CareerCertification> Certifications { get; set; } = new();
public List<CareerLanguage> Languages { get; set; } = new();
}
// Append-only history: one row per save, so profile edits are never silently lost.