feat: add career profile foundation with versioned history
Introduces the Career Workspace's bounded data foundation, additive and backwards-compatible: ApplicationUser.ProfileCvStructureJson stays the authoritative column every existing read path uses; the new CareerProfiles/CareerProfileVersions tables mirror it via ICareerProfileService so future Career Workspace features (variants, history UI) have real tables to build on rather than starting a second migration later. - CareerProfile: one snapshot row per user (Version, ProfileJson). - CareerProfileVersion: append-only history, one row per save (upload/rebuild/improve/reprocess/parse), so a profile edit is never silently lost the way ProfileCvStructureJson overwrites are today. - Stable item IDs assigned to jobs/education/certifications/projects on first save and preserved across later saves -- the prerequisite for CV variants to reference "this job" by identity instead of array position. - CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for job/education/certification/project date ranges, kept alongside (never replacing) the original free-string fields. - Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects, matching this repo's schema-via-raw-SQL-reconciler convention rather than EF migrations. Job tracking is untouched -- this is entirely within the profile/CV domain per the Career Workspace product boundary.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
|
||||
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
|
||||
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
|
||||
// single place stable item IDs and normalized dates get assigned, and where profile history is
|
||||
// captured so it's never silently overwritten on the next rebuild/improve/upload.
|
||||
public interface ICareerProfileService
|
||||
{
|
||||
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
|
||||
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, and
|
||||
// returns the same profile so the caller can go on to serialize it into the legacy column.
|
||||
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class CareerProfileService : ICareerProfileService
|
||||
{
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public CareerProfileService(JobTrackerContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
|
||||
{
|
||||
AssignStableIds(profile);
|
||||
NormalizeDates(profile);
|
||||
|
||||
var json = StructuredCvProfileJson.Serialize(profile);
|
||||
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new CareerProfile
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
ProfileJson = json,
|
||||
Version = 1,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CareerProfiles.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ProfileJson = json;
|
||||
existing.Version += 1;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_db.CareerProfileVersions.Add(new CareerProfileVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CareerProfileId = existing.Id,
|
||||
Version = existing.Version,
|
||||
ProfileJson = json,
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static void AssignStableIds(StructuredCvProfile profile)
|
||||
{
|
||||
foreach (var job in profile.Jobs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
|
||||
}
|
||||
foreach (var education in profile.Education)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
|
||||
}
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
|
||||
}
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
private static void NormalizeDates(StructuredCvProfile profile)
|
||||
{
|
||||
foreach (var job in profile.Jobs)
|
||||
{
|
||||
job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
|
||||
job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
|
||||
}
|
||||
foreach (var education in profile.Education)
|
||||
{
|
||||
education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
|
||||
education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
|
||||
}
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
|
||||
}
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
|
||||
project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
|
||||
// free-string field is always kept alongside whatever this returns (null on anything it can't
|
||||
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
|
||||
public static class CvDateNormalizer
|
||||
{
|
||||
private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["jan"] = 1, ["january"] = 1,
|
||||
["feb"] = 2, ["february"] = 2,
|
||||
["mar"] = 3, ["march"] = 3,
|
||||
["apr"] = 4, ["april"] = 4,
|
||||
["may"] = 5,
|
||||
["jun"] = 6, ["june"] = 6,
|
||||
["jul"] = 7, ["july"] = 7,
|
||||
["aug"] = 8, ["august"] = 8,
|
||||
["sep"] = 9, ["sept"] = 9, ["september"] = 9,
|
||||
["oct"] = 10, ["october"] = 10,
|
||||
["nov"] = 11, ["november"] = 11,
|
||||
["dec"] = 12, ["december"] = 12,
|
||||
};
|
||||
|
||||
public static string? TryParseYearMonth(string? raw)
|
||||
{
|
||||
var value = (raw ?? string.Empty).Trim();
|
||||
if (value.Length == 0) return null;
|
||||
if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
// "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
|
||||
var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
|
||||
if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
|
||||
{
|
||||
return $"{monthYear.Groups["year"].Value}-{month:D2}";
|
||||
}
|
||||
|
||||
var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
|
||||
if (slash.Success)
|
||||
{
|
||||
var m = int.Parse(slash.Groups["month"].Value);
|
||||
if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
|
||||
}
|
||||
|
||||
var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
|
||||
if (isoLike.Success)
|
||||
{
|
||||
var m = int.Parse(isoLike.Groups["month"].Value);
|
||||
if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -623,10 +623,45 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||||
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
|
||||
// authoritative column every existing read path uses; these mirror it so future
|
||||
// Career Workspace features (variants, history UI) have a real table to build on.
|
||||
static void EnsureCareerProfileTables(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"ProfileJson" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
"UpdatedAtUtc" TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CareerProfileId" INTEGER NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"ProfileJson" TEXT NOT NULL,
|
||||
"Source" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
EnsureCvTables(conn);
|
||||
EnsureCareerProfileTables(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -752,6 +787,22 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||
@@ -977,6 +1028,41 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||||
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
|
||||
// for every existing read path during the dual-write window.
|
||||
if (!HasMySqlTable(conn, "CareerProfiles"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`ProfileJson` longtext NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "CareerProfileVersions"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`CareerProfileId` int NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`ProfileJson` longtext NOT NULL,
|
||||
`Source` varchar(100) NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
Reference in New Issue
Block a user