feat(career): structured career profile foundation
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:
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user