|
|
|
@@ -15,12 +15,17 @@ 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, syncs
|
|
|
|
|
// the relational children (Experience/Education/Skill/Project/Certification/Language), 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);
|
|
|
|
|
// returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
|
|
|
|
|
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken);
|
|
|
|
|
|
|
|
|
|
Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken);
|
|
|
|
|
|
|
|
|
|
// Includes Identity projection and import metadata in the canonical commit.
|
|
|
|
|
Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken);
|
|
|
|
|
|
|
|
|
|
// Reads the master profile from the relational children (the source of truth). If the relational
|
|
|
|
|
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
|
|
|
|
|
// backfilled from the blob first. Returns an empty profile if the user has none.
|
|
|
|
|
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
|
|
|
|
|
// back to that blob without writing. Returns an empty profile if the user has none.
|
|
|
|
|
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
|
|
|
|
|
|
|
|
|
|
// The append-only version history, newest first.
|
|
|
|
@@ -29,7 +34,7 @@ public interface ICareerProfileService
|
|
|
|
|
// Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
|
|
|
|
|
// and the current state are both preserved; the restore is itself reversible). Returns the
|
|
|
|
|
// restored profile, or null if the version does not exist.
|
|
|
|
|
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
|
|
|
|
|
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken);
|
|
|
|
|
|
|
|
|
|
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
|
|
|
|
|
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
|
|
|
|
@@ -38,6 +43,8 @@ public interface ICareerProfileService
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
|
|
|
|
|
public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null);
|
|
|
|
|
public sealed class CareerProjectionException(string message) : Exception(message);
|
|
|
|
|
|
|
|
|
|
public sealed class CareerProfileService : ICareerProfileService
|
|
|
|
|
{
|
|
|
|
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
|
|
|
|
|
_db = db;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
|
|
|
|
|
public async Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
AssignStableIds(profile);
|
|
|
|
|
NormalizeDates(profile);
|
|
|
|
|
|
|
|
|
|
// Publish the profile row, relational children, and history revision atomically. Without
|
|
|
|
|
// this boundary a concurrent outline/read can observe the new profile before its children,
|
|
|
|
|
// enter the legacy backfill path, and insert a second copy of every child row.
|
|
|
|
|
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
|
|
|
|
|
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
|
|
|
|
|
|
|
|
|
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
|
|
|
if (existing is null)
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
existing = new CareerProfile
|
|
|
|
|
var result = await action();
|
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
|
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
// A later SaveChanges must never publish an operation whose response failed.
|
|
|
|
|
_db.ChangeTracker.Clear();
|
|
|
|
|
}
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// Hold a read snapshot across the canonical row and relational children.
|
|
|
|
|
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
|
|
|
|
|
? await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken)
|
|
|
|
|
: null;
|
|
|
|
|
var row = await _db.CareerProfiles.AsNoTracking().Where(x => x.OwnerUserId == ownerUserId)
|
|
|
|
|
.Select(x => new
|
|
|
|
|
{
|
|
|
|
|
x.Version,
|
|
|
|
|
CvText = _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefault(),
|
|
|
|
|
}).FirstOrDefaultAsync(cancellationToken);
|
|
|
|
|
var snapshot = row is null ? new CareerProfileSnapshot(new StructuredCvProfile(), 0,
|
|
|
|
|
await _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefaultAsync(cancellationToken))
|
|
|
|
|
: new(await LoadStructuredAsync(ownerUserId, cancellationToken), row.Version, row.CvText);
|
|
|
|
|
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
|
|
|
|
return snapshot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken) =>
|
|
|
|
|
ExecuteAtomicAsync(async () =>
|
|
|
|
|
{
|
|
|
|
|
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
|
|
|
if (expectedVersion < 0 || (existing?.Version ?? 0) != expectedVersion)
|
|
|
|
|
throw new DbUpdateConcurrencyException("The career profile changed after it was loaded.");
|
|
|
|
|
|
|
|
|
|
AssignStableIds(profile);
|
|
|
|
|
NormalizeDates(profile);
|
|
|
|
|
|
|
|
|
|
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
catch (DbUpdateException ex) when (expectedVersion == 0 && IsDuplicateOwner(ex))
|
|
|
|
|
{
|
|
|
|
|
throw new DbUpdateConcurrencyException("The career profile was created by another request.", ex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
|
|
|
|
|
|
|
|
|
|
_db.CareerProfileVersions.Add(new CareerProfileVersion
|
|
|
|
|
{
|
|
|
|
|
OwnerUserId = ownerUserId,
|
|
|
|
|
CareerProfileId = existing.Id,
|
|
|
|
|
Version = existing.Version,
|
|
|
|
|
ProfileJson = json,
|
|
|
|
|
Version = 1,
|
|
|
|
|
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
|
|
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
|
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
|
|
|
|
};
|
|
|
|
|
_db.CareerProfiles.Add(existing);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
existing.ProfileJson = json;
|
|
|
|
|
existing.Version += 1;
|
|
|
|
|
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return profile;
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
|
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, 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);
|
|
|
|
|
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
return profile;
|
|
|
|
|
}
|
|
|
|
|
private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
|
|
|
|
|
{
|
|
|
|
|
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
|
|
|
|
|
&& sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
|
|
|
|
|
MySqlConnector.MySqlException mysql => mysql.Number == 1062
|
|
|
|
|
&& mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
|
|
|
|
|
_ => false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
|
|
|
var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
|
|
|
if (profile is null) return new StructuredCvProfile();
|
|
|
|
|
|
|
|
|
|
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|
|
|
|
@@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService
|
|
|
|
|
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|
|
|
|
|
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
|
|
|
|
|
|
|
|
|
|
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
|
|
|
|
|
// A read cannot materialize unversioned children during a concurrent replacement.
|
|
|
|
|
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
|
|
|
|
|
{
|
|
|
|
|
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
|
|
|
|
|
AssignStableIds(fromBlob);
|
|
|
|
|
NormalizeDates(fromBlob);
|
|
|
|
|
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
|
|
|
|
|
return fromBlob;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
|
|
|
|
|
|
|
|
|
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
|
|
|
|
}
|
|
|
|
@@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
|
|
|
|
return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken)
|
|
|
|
|
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
|
|
|
if (profile is null) return null;
|
|
|
|
@@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
|
|
|
|
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
|
|
|
|
|
// history, so a restore can itself be undone by restoring the version before it.
|
|
|
|
|
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
|
|
|
|
|
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
|
|
|
|
|
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
|
|
|
|
|