using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc); public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent); // Person + photo needed to render, resolved from ApplicationUser by the controller so this service // stays off the Identity store. public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl); public interface ICvVariantService { Task> ListAsync(string ownerUserId, CancellationToken ct); Task GetAsync(string ownerUserId, int id, CancellationToken ct); Task CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct); Task SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct); Task SetPublicAsync(string ownerUserId, int id, bool isPublic, CancellationToken ct); Task DuplicateAsync(string ownerUserId, int id, string? newName, CancellationToken ct); Task DeleteAsync(string ownerUserId, int id, CancellationToken ct); Task> ListVersionsAsync(string ownerUserId, int id, CancellationToken ct); Task RestoreVersionAsync(string ownerUserId, int id, int version, CancellationToken ct); // Render a saved variant against the current master profile. Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct); // Render arbitrary (unsaved) settings against the current master profile — live preview. Task RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct); // The master profile resolved to sections+entries (with ItemKeys) for the Content tab to edit. Task OutlineAsync(string ownerUserId, CvRenderPerson person, CancellationToken ct); // Render a public variant by slug (anonymous). Returns null if the slug is unknown or not public. Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct); Task GetPublicOwnerAsync(string publicSlug, CancellationToken ct); } public sealed class CvVariantService : ICvVariantService { private readonly JobTrackerContext _db; private readonly ICareerProfileService _career; private readonly IThemedCvRenderer _renderer; public CvVariantService(JobTrackerContext db, ICareerProfileService career, IThemedCvRenderer renderer) { _db = db; _career = career; _renderer = renderer; } public async Task> ListAsync(string ownerUserId, CancellationToken ct) { var variants = await _db.CvVariants.Where(x => x.OwnerUserId == ownerUserId) .OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct); return variants.Select(Summarize).ToList(); } public Task GetAsync(string ownerUserId, int id, CancellationToken ct) => _db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct); public async Task CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct) { var now = DateTimeOffset.UtcNow; var normalized = CvVariantSettingsJson.Normalize(settings); var variant = new CvVariant { OwnerUserId = ownerUserId, PublicSlug = NewSlug(), Name = CleanName(name), JobApplicationId = jobApplicationId, SettingsJson = CvVariantSettingsJson.Serialize(normalized), Version = 1, CreatedAtUtc = now, UpdatedAtUtc = now, }; _db.CvVariants.Add(variant); await _db.SaveChangesAsync(ct); AppendVersion(variant, "create"); await _db.SaveChangesAsync(ct); return variant; } public async Task SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return null; if (!string.IsNullOrWhiteSpace(name)) variant.Name = CleanName(name); variant.SettingsJson = CvVariantSettingsJson.Serialize(settings); variant.Version += 1; variant.UpdatedAtUtc = DateTimeOffset.UtcNow; AppendVersion(variant, string.IsNullOrWhiteSpace(source) ? "autosave" : source.Trim()); await _db.SaveChangesAsync(ct); return variant; } public async Task SetPublicAsync(string ownerUserId, int id, bool isPublic, CancellationToken ct) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return null; if (isPublic && !variant.IsPublic) variant.PublicSlug = NewSlug(); variant.IsPublic = isPublic; variant.UpdatedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(ct); return variant; } public async Task DuplicateAsync(string ownerUserId, int id, string? newName, CancellationToken ct) { var source = await GetAsync(ownerUserId, id, ct); if (source is null) return null; var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson); return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct); } public async Task DeleteAsync(string ownerUserId, int id, CancellationToken ct) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return false; _db.CvVariantVersions.RemoveRange(_db.CvVariantVersions.Where(x => x.CvVariantId == id)); _db.CvVariants.Remove(variant); await _db.SaveChangesAsync(ct); return true; } public async Task> ListVersionsAsync(string ownerUserId, int id, CancellationToken ct) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return Array.Empty(); var versions = await _db.CvVariantVersions.Where(x => x.CvVariantId == id) .OrderByDescending(x => x.Version) .Select(x => new { x.Version, x.Source, x.CreatedAtUtc }).ToListAsync(ct); return versions.Select(x => new CvVariantVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == variant.Version)).ToList(); } public async Task RestoreVersionAsync(string ownerUserId, int id, int version, CancellationToken ct) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return null; var target = await _db.CvVariantVersions.FirstOrDefaultAsync(x => x.CvVariantId == id && x.Version == version, ct); if (target is null) return null; var settings = CvVariantSettingsJson.Deserialize(target.SettingsJson); return await SaveAsync(ownerUserId, id, null, settings, $"restore:v{version}", ct); } public async Task RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct) { var variant = await GetAsync(ownerUserId, id, ct); if (variant is null) return null; var settings = CvVariantSettingsJson.Deserialize(variant.SettingsJson); var profile = await _career.LoadStructuredAsync(ownerUserId, ct); return RenderInternal(profile, settings, person); } public async Task RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct) { var profile = await _career.LoadStructuredAsync(ownerUserId, ct); return RenderInternal(profile, settings, person); } public async Task OutlineAsync(string ownerUserId, CvRenderPerson person, CancellationToken ct) { var profile = await _career.LoadStructuredAsync(ownerUserId, ct); return CvVariantResolver.Build(profile, new CvVariantSettings(), person.FallbackName, person.PhotoDataUrl); } public async Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct) { var variant = await _db.CvVariants.IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.PublicSlug == publicSlug && x.IsPublic, ct); if (variant is null) return null; var settings = CvVariantSettingsJson.Deserialize(variant.SettingsJson); var profile = await _career.LoadStructuredForOwnerAsync(variant.OwnerUserId, ct); return (RenderInternal(profile, settings, person), variant.OwnerUserId); } public async Task GetPublicOwnerAsync(string publicSlug, CancellationToken ct) { return await _db.CvVariants.IgnoreQueryFilters() .Where(x => x.PublicSlug == publicSlug && x.IsPublic) .Select(x => x.OwnerUserId).FirstOrDefaultAsync(ct); } private ThemedCvRenderResult RenderInternal(StructuredCvProfile profile, CvVariantSettings settings, CvRenderPerson person) { var theme = CvThemeCatalog.Resolve(settings.ThemeId); var model = CvVariantResolver.Build(profile, settings, person.FallbackName, person.PhotoDataUrl); return _renderer.Render(model, theme, settings); } private void AppendVersion(CvVariant variant, string source) => _db.CvVariantVersions.Add(new CvVariantVersion { OwnerUserId = variant.OwnerUserId, CvVariantId = variant.Id, Version = variant.Version, SettingsJson = variant.SettingsJson, Source = source, CreatedAtUtc = DateTimeOffset.UtcNow, }); private static CvVariantSummary Summarize(CvVariant v) { var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson); return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc); } private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim(); private static string NewSlug() => Guid.NewGuid().ToString("N"); }