diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index d7b8e18..cda94fd 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -42,6 +42,8 @@ namespace JobTrackerApi.Data public DbSet CareerLanguages => Set(); public DbSet InterviewPrepNotes => Set(); public DbSet AiWorkspaceNotes => Set(); + public DbSet CvVariants => Set(); + public DbSet CvVariantVersions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -288,6 +290,33 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // Phase 4: CV variants (a lens over the master profile) + their autosave history. + // docs/architecture/cv-builder.md. Same deny-on-null tenant filter as everything else. A + // variant references a job application (reference-not-ownership, SetNull on delete) and never + // owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + modelBuilder.Entity() + .HasIndex(x => x.PublicSlug) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc }); + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + modelBuilder.Entity() + .HasIndex(x => new { x.CvVariantId, x.Version }); + modelBuilder.Entity() + .HasOne(x => x.CvVariant) + .WithMany() + .HasForeignKey(x => x.CvVariantId) + .OnDelete(DeleteBehavior.Cascade); } // Common config for CareerProfile's relational children. The 1:many FK + cascade delete is diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs new file mode 100644 index 0000000..b696ad4 --- /dev/null +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -0,0 +1,243 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CvBuilderTests +{ + private static StructuredCvProfile Rich() => new() + { + Contact = { FullName = "Ada Lovelace", Headline = "Engineer", Email = "ada@example.com", Location = "Oslo" }, + Summary = { "Builder of engines." }, + Jobs = + { + new StructuredCvJob { Id = "job1", Title = "Senior Eng", Company = "Acme", Location = "Oslo", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } }, + new StructuredCvJob { Id = "job2", Title = "Eng", Company = "Beta", Start = "2018", End = "2019", Bullets = { "Built Y" } }, + }, + Education = { new StructuredCvEducation { Id = "ed1", Qualification = "BSc", Institution = "Uni" } }, + Skills = { "C#", "SQL" }, + Projects = { new StructuredCvProject { Id = "pr1", Name = "Proj", Role = "Lead", Skills = { "React" } } }, + Certifications = { new StructuredCvCertification { Id = "c1", Name = "AZ-204", Issuer = "MS" } }, + Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } }, + }; + + // ---- Resolver (the lens over the master profile) ---- + + [Fact] + public void Resolver_defaults_include_every_populated_section() + { + var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "Fallback", null); + Assert.Equal("Ada Lovelace", model.FullName); + var keys = model.Sections.Select(s => s.Key).ToList(); + Assert.Contains("summary", keys); + Assert.Contains("experience", keys); + Assert.Contains("skills", keys); + Assert.Contains("languages", keys); + } + + [Fact] + public void Resolver_hides_a_section_when_configured() + { + var settings = new CvVariantSettings { Sections = { new CvSectionSetting { Key = "skills", Hidden = true } } }; + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + Assert.DoesNotContain("skills", model.Sections.Select(s => s.Key)); + // Other sections still render (appended after the explicit order). + Assert.Contains("experience", model.Sections.Select(s => s.Key)); + } + + [Fact] + public void Resolver_respects_explicit_section_order() + { + var settings = new CvVariantSettings + { + Sections = { new CvSectionSetting { Key = "skills" }, new CvSectionSetting { Key = "summary" } }, + }; + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + var keys = model.Sections.Select(s => s.Key).ToList(); + Assert.True(keys.IndexOf("skills") < keys.IndexOf("summary")); + } + + [Fact] + public void Resolver_applies_item_override_bullets_without_touching_the_master() + { + var profile = Rich(); + var settings = new CvVariantSettings(); + settings.Overrides["job1"] = new CvItemOverride { Bullets = new() { "Tailored bullet" } }; + var model = CvVariantResolver.Build(profile, settings, "F", null); + var exp = model.Sections.First(s => s.Key == "experience"); + Assert.Equal(new[] { "Tailored bullet" }, exp.Entries[0].Bullets); + // Master profile is untouched — the override is a lens, not a write. + Assert.Equal(new[] { "Built X" }, profile.Jobs[0].Bullets); + } + + [Fact] + public void Resolver_hides_a_single_item_by_key() + { + var settings = new CvVariantSettings(); + settings.Overrides["job2"] = new CvItemOverride { Hidden = true }; + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + var exp = model.Sections.First(s => s.Key == "experience"); + Assert.Single(exp.Entries); + Assert.Equal("Senior Eng", exp.Entries[0].Title); + } + + [Fact] + public void Resolver_adds_variant_only_custom_sections() + { + var settings = new CvVariantSettings + { + CustomSections = { new CvCustomSectionSetting { Key = "vol", Title = "Volunteering", Items = { "Coached juniors" } } }, + }; + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + Assert.Contains(model.Sections, s => s.Title == "Volunteering" && s.Bullets.Contains("Coached juniors")); + } + + // ---- Renderer (one path, every theme is data) ---- + + [Fact] + public void Every_catalog_theme_renders_valid_html() + { + var renderer = new ThemedCvRenderer(); + var model = CvVariantResolver.Build(Rich(), new CvVariantSettings { ShowPhoto = true }, "F", "data:image/png;base64,AAAA"); + foreach (var theme in CvThemeCatalog.Themes) + { + var result = renderer.Render(model, theme, new CvVariantSettings { ThemeId = theme.Id }); + Assert.Contains("() + .UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + var svc = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer()); + return (db, svc); + } + + [Fact] + public async Task Create_then_save_increments_version_and_keeps_history() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + var created = await svc.CreateAsync("user-1", "My CV", null, new CvVariantSettings { ThemeId = "modern" }, default); + Assert.Equal(1, created.Version); + + var saved = await svc.SaveAsync("user-1", created.Id, "My CV v2", new CvVariantSettings { ThemeId = "nordic" }, "autosave", default); + Assert.Equal(2, saved!.Version); + + var versions = await svc.ListVersionsAsync("user-1", created.Id, default); + Assert.Equal(new[] { 2, 1 }, versions.Select(v => v.Version)); + Assert.True(versions[0].IsCurrent); + } + + [Fact] + public async Task Restore_reapplies_old_settings_as_a_new_version() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default); + await svc.SaveAsync("user-1", v.Id, null, new CvVariantSettings { ThemeId = "creative" }, "autosave", default); + + var restored = await svc.RestoreVersionAsync("user-1", v.Id, 1, default); + Assert.Equal(3, restored!.Version); + Assert.Equal("modern", CvVariantSettingsJson.Deserialize(restored.SettingsJson).ThemeId); + } + + [Fact] + public async Task Duplicate_copies_settings_with_a_fresh_slug() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "elegant" }, default); + var copy = await svc.DuplicateAsync("user-1", v.Id, null, default); + Assert.NotEqual(v.PublicSlug, copy!.PublicSlug); + Assert.Equal("elegant", CvVariantSettingsJson.Deserialize(copy.SettingsJson).ThemeId); + } + + [Fact] + public async Task Delete_removes_the_variant_and_its_versions() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + var v = await svc.CreateAsync("user-1", "CV", null, null, default); + Assert.True(await svc.DeleteAsync("user-1", v.Id, default)); + Assert.Null(await svc.GetAsync("user-1", v.Id, default)); + Assert.Empty(await db.CvVariantVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == v.Id).ToListAsync()); + } + + [Fact] + public async Task Render_pulls_content_from_the_master_profile() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", default); + var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default); + + var render = await svc.RenderAsync("user-1", v.Id, new CvRenderPerson("Fallback", null), default); + Assert.NotNull(render); + Assert.Contains("Ada Lovelace", render!.Html); + Assert.Contains("Senior Eng", render.Html); + } + + [Fact] + public async Task Public_render_bypasses_the_tenant_filter() + { + var dbName = Guid.NewGuid().ToString(); + var (ownerDb, ownerSvc) = NewService("user-1", dbName); + await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", default); + var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default); + await ownerSvc.SetPublicAsync("user-1", v.Id, true, default); + await ownerDb.DisposeAsync(); + + // Anonymous context: no current user, so the tenant filter denies everything by default. + var (anonDb, anonSvc) = NewService(null!, dbName); + await using var _ = anonDb; + var owner = await anonSvc.GetPublicOwnerAsync(v.PublicSlug, default); + Assert.Equal("user-1", owner); + var result = await anonSvc.RenderPublicAsync(v.PublicSlug, new CvRenderPerson("Ada Lovelace", null), default); + Assert.NotNull(result); + Assert.Contains("Ada Lovelace", result!.Value.render.Html); + } + + [Fact] + public async Task Private_variant_is_not_served_publicly() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + var v = await svc.CreateAsync("user-1", "CV", null, null, default); + Assert.Null(await svc.GetPublicOwnerAsync(v.PublicSlug, default)); + } +} diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs new file mode 100644 index 0000000..0c5d9db --- /dev/null +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -0,0 +1,226 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Phase 4 — Career Workspace Builder API. Every endpoint reads the master profile through +// ICareerProfileService and renders through the data-driven theme engine; none of them store career +// data. docs/architecture/cv-builder.md. +[ApiController] +[Route("api/cv")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class CvVariantController : ControllerBase +{ + private readonly UserManager _users; + private readonly ICvVariantService _variants; + private readonly ICvPdfExporter _pdf; + private readonly ISummarizerService _ai; + + public CvVariantController(UserManager users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai) + { + _users = users; + _variants = variants; + _pdf = pdf; + _ai = ai; + } + + public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc); + public sealed record CreateVariantRequest(string? Name, int? JobApplicationId, CvVariantSettings? Settings); + public sealed record SaveVariantRequest(string? Name, CvVariantSettings? Settings, string? Source); + public sealed record PublicToggleRequest(bool IsPublic); + public sealed record DuplicateRequest(string? Name); + public sealed record PreviewRequest(CvVariantSettings? Settings); + public sealed record RenderDto(string ThemeId, string Html, string SuggestedFileName); + public sealed record AiAssistRequest(string? Action, string? Text, string? Context, string? Role, string? Language); + public sealed record AiAssistResult(string Original, string Result); + + [HttpGet("themes")] + public ActionResult> GetThemes() + { + var themes = CvThemeCatalog.Themes.Select(t => new + { + id = t.Id, + name = t.Name, + category = t.Category, + description = t.Description, + layout = t.Layout, + accent = t.Accent, + photoShape = t.PhotoShape, + supportsIcons = t.DefaultIcons, + swatches = new[] { t.Accent, t.SidebarBg, t.Paper }, + }); + return Ok(themes); + } + + [HttpGet("variants")] + public async Task>> List(CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + return Ok(await _variants.ListAsync(user.Id, ct)); + } + + [HttpPost("variants")] + public async Task> Create([FromBody] CreateVariantRequest? request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId)) + return BadRequest("Unknown theme."); + var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct); + return Ok(ToDto(variant)); + } + + [HttpGet("variants/{id:int}")] + public async Task> Get(int id, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var variant = await _variants.GetAsync(user.Id, id, ct); + return variant is null ? NotFound() : Ok(ToDto(variant)); + } + + [HttpPut("variants/{id:int}")] + public async Task> Save(int id, [FromBody] SaveVariantRequest request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var settings = CvVariantSettingsJson.Normalize(request.Settings); + if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme."); + var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct); + return variant is null ? NotFound() : Ok(ToDto(variant)); + } + + [HttpDelete("variants/{id:int}")] + public async Task Delete(int id, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + return await _variants.DeleteAsync(user.Id, id, ct) ? NoContent() : NotFound(); + } + + [HttpPost("variants/{id:int}/duplicate")] + public async Task> Duplicate(int id, [FromBody] DuplicateRequest? request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var variant = await _variants.DuplicateAsync(user.Id, id, request?.Name, ct); + return variant is null ? NotFound() : Ok(ToDto(variant)); + } + + [HttpPut("variants/{id:int}/public")] + public async Task> SetPublic(int id, [FromBody] PublicToggleRequest request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var variant = await _variants.SetPublicAsync(user.Id, id, request.IsPublic, ct); + return variant is null ? NotFound() : Ok(ToDto(variant)); + } + + [HttpGet("variants/{id:int}/versions")] + public async Task>> Versions(int id, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + return Ok(await _variants.ListVersionsAsync(user.Id, id, ct)); + } + + [HttpPost("variants/{id:int}/versions/{version:int}/restore")] + public async Task> Restore(int id, int version, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var variant = await _variants.RestoreVersionAsync(user.Id, id, version, ct); + return variant is null ? NotFound() : Ok(ToDto(variant)); + } + + [HttpGet("variants/{id:int}/preview")] + public async Task> Preview(int id, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var render = await _variants.RenderAsync(user.Id, id, Person(user), ct); + return render is null ? NotFound() : Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName)); + } + + // Live preview for unsaved builder settings. + [HttpPost("preview")] + public async Task> PreviewSettings([FromBody] PreviewRequest request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var settings = CvVariantSettingsJson.Normalize(request.Settings); + var render = await _variants.RenderSettingsAsync(user.Id, settings, Person(user), ct); + return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName)); + } + + [HttpPost("variants/{id:int}/export-pdf")] + public async Task ExportPdf(int id, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var render = await _variants.RenderAsync(user.Id, id, Person(user), ct); + if (render is null) return NotFound(); + var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct); + return File(artifact.Bytes, "application/pdf", artifact.FileName); + } + + // AI assistance on any text area. Never mutates the profile or variant — returns a suggestion the + // user reviews and applies themselves. Reuses the existing provider abstraction (ISummarizerService). + [HttpPost("ai/assist")] + public async Task> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var text = (request.Text ?? string.Empty).Trim(); + if (text.Length == 0) return BadRequest("Provide text to work on."); + + var instruction = BuildAiInstruction(request.Action, request.Role, request.Language, request.Context); + var result = await _ai.SummarizeSectionAsync(instruction, text, 1200, 300); + if (string.IsNullOrWhiteSpace(result)) + { + var metrics = await _ai.GetMetricsAsync(ct); + return StatusCode(StatusCodes.Status502BadGateway, metrics.Healthy + ? "The AI service returned no usable text." + : "The AI service is unavailable right now."); + } + return Ok(new AiAssistResult(text, result.Trim())); + } + + private static string BuildAiInstruction(string? action, string? role, string? language, string? context) + { + var target = string.IsNullOrWhiteSpace(role) ? null : role.Trim(); + var lang = string.IsNullOrWhiteSpace(language) ? "the same language as the input" : language.Trim(); + var extra = string.IsNullOrWhiteSpace(context) ? string.Empty : $" Context: {context.Trim()}."; + var task = (action ?? "improve").Trim().ToLowerInvariant() switch + { + "professional" => "Rewrite the text in a more professional, confident tone.", + "shorten" => "Make the text more concise without losing meaning.", + "expand" => "Expand the text with more concrete, relevant detail — but never invent facts.", + "grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.", + "ats" => "Rewrite for ATS keyword optimisation: strong action verbs, quantified impact, clear phrasing. Do not invent facts.", + "bullets" => "Rewrite as 3–5 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.", + "summary" => "Write a concise professional summary (2–3 sentences) from this text. Do not invent facts.", + "rewrite" => "Rewrite the text more clearly and compellingly, preserving all facts.", + "tailor" => $"Rewrite the text to align with the target role{(target is null ? string.Empty : $" '{target}'")}, emphasising the most relevant experience. Do not invent facts.", + _ => "Improve the clarity, impact and phrasing of the text while preserving all facts.", + }; + return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}"; + } + + private static CvRenderPerson Person(ApplicationUser user) + { + var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = user.Email?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = "Your Name"; + return new CvRenderPerson(name!, user.AvatarImageDataUrl); + } + + private static VariantDto ToDto(CvVariant v) => new( + v.Id, v.Name, CvVariantSettingsJson.Deserialize(v.SettingsJson), v.IsPublic, v.PublicSlug, v.Version, v.JobApplicationId, v.UpdatedAtUtc); +} diff --git a/JobTrackerApi/Controllers/PublicCvController.cs b/JobTrackerApi/Controllers/PublicCvController.cs new file mode 100644 index 0000000..95955ac --- /dev/null +++ b/JobTrackerApi/Controllers/PublicCvController.cs @@ -0,0 +1,49 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Anonymous read-only surface for public CVs: /cv/{slug} in the SPA fetches this. Only variants the +// owner has explicitly toggled public are served; noindex by default. docs/architecture/cv-builder.md. +[ApiController] +[Route("api/public-cv")] +[AllowAnonymous] +public sealed class PublicCvController : ControllerBase +{ + private readonly UserManager _users; + private readonly ICvVariantService _variants; + + public PublicCvController(UserManager users, ICvVariantService variants) + { + _users = users; + _variants = variants; + } + + [HttpGet("{slug}")] + public async Task Get(string slug, CancellationToken ct) + { + var ownerId = await _variants.GetPublicOwnerAsync(slug, ct); + if (ownerId is null) return NotFound(); + + var owner = await _users.FindByIdAsync(ownerId); + var person = Person(owner); + var result = await _variants.RenderPublicAsync(slug, person, ct); + if (result is null) return NotFound(); + + Response.Headers["X-Robots-Tag"] = "noindex, nofollow"; + return Ok(new { html = result.Value.render.Html, name = person.FallbackName }); + } + + private static CvRenderPerson Person(ApplicationUser? user) + { + if (user is null) return new CvRenderPerson("Candidate", null); + var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = "Candidate"; + return new CvRenderPerson(name!, user.AvatarImageDataUrl); + } +} diff --git a/JobTrackerApi/Migrations/20260718074509_AddCvVariants.Designer.cs b/JobTrackerApi/Migrations/20260718074509_AddCvVariants.Designer.cs new file mode 100644 index 0000000..d01a06a --- /dev/null +++ b/JobTrackerApi/Migrations/20260718074509_AddCvVariants.Designer.cs @@ -0,0 +1,2064 @@ +// +using System; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + [DbContext(typeof(JobTrackerContext))] + [Migration("20260718074509_AddCvVariants")] + partial class AddCvVariants + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NoteType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AvatarImageDataUrl") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentCvExtractionRunId") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvUploadArtifactId") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("GoogleEmail") + .HasColumnType("TEXT"); + + b.Property("GoogleLinkedAt") + .HasColumnType("TEXT"); + + b.Property("GoogleSubject") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfileCvStructureJson") + .HasColumnType("TEXT"); + + b.Property("ProfileCvText") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Purpose") + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .HasColumnType("TEXT"); + + b.Property("UseForAi") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateNormalized") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Institution") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualification") + .HasColumnType("TEXT"); + + b.Property("QualificationLevel") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Company") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LongTailJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId") + .IsUnique(); + + b.ToTable("CareerProfiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Proficiency") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastContactedAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextContactAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PipelineStage") + .HasColumnType("TEXT"); + + b.Property("RecruiterEmail") + .HasColumnType("TEXT"); + + b.Property("RecruiterLinkedIn") + .HasColumnType("TEXT"); + + b.Property("RecruiterName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentMetadataJson") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("ExternalFrom") + .HasColumnType("TEXT"); + + b.Property("ExternalLabelsJson") + .HasColumnType("TEXT"); + + b.Property("ExternalMessageId") + .HasColumnType("TEXT"); + + b.Property("ExternalThreadId") + .HasColumnType("TEXT"); + + b.Property("ExternalTo") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Correspondences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ArtifactId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LlmPromptVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedText") + .HasColumnType("TEXT"); + + b.Property("NormalizerVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawExtractedText") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StructuredProfileJson") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtifactId"); + + b.HasIndex("OwnerUserId", "StartedAtUtc"); + + b.ToTable("CvExtractionRuns"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "UploadedAtUtc"); + + b.ToTable("CvUploadArtifacts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("PublicSlug") + .IsUnique(); + + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + + b.ToTable("CvVariants"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CvVariantId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CvVariantId", "Version"); + + b.ToTable("CvVariantVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GmailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "GmailAddress") + .IsUnique(); + + b.ToTable("GmailConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GmailReviewDecisions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UseSsl") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LikelyQuestionsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TalkingPointsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CoverLetterText") + .HasColumnType("TEXT"); + + b.Property("DateApplied") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("FeedbackRequestedAt") + .HasColumnType("TEXT"); + + b.Property("FollowUpAt") + .HasColumnType("TEXT"); + + b.Property("HasCoverLetter") + .HasColumnType("INTEGER"); + + b.Property("HasOtherAttachment") + .HasColumnType("INTEGER"); + + b.Property("HasPortfolio") + .HasColumnType("INTEGER"); + + b.Property("HasResume") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("LastReminderEmailSentAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("NextAction") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("RecruiterMessageDraft") + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseReceived") + .HasColumnType("INTEGER"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TailoredCvText") + .HasColumnType("TEXT"); + + b.Property("TailoredCvUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "FollowUpAt"); + + b.HasIndex("OwnerUserId", "IsDeleted"); + + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NewValue") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OldValue") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MicrosoftGraphConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RuleSettings"); + + b.HasData( + new + { + Id = 1, + AppliedFollowUpDays = 14, + AppliedGhostDays = 30, + FeedbackFollowUpDays = 7, + FeedbackGhostDays = 14, + OfferFollowUpDays = 7, + OfferGhostDays = 14 + }); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SystemEmailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasColumnType("TEXT"); + + b.Property("SmtpEnableSsl") + .HasColumnType("INTEGER"); + + b.Property("SmtpHost") + .HasColumnType("TEXT"); + + b.Property("SmtpPassword") + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpTimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("SmtpUser") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemEmailSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CustomSectionsJson") + .HasColumnType("TEXT"); + + b.Property("EducationJson") + .HasColumnType("TEXT"); + + b.Property("ExperienceJson") + .HasColumnType("TEXT"); + + b.Property("GenerationContextHash") + .HasColumnType("TEXT"); + + b.Property("Headline") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LastEditedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastGeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RenderOptionsJson") + .HasColumnType("TEXT"); + + b.Property("SelectedSkillsJson") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SummaryJson") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => + { + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("OwnerUserId"); + + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + 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") + .WithMany("Attachments") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Messages") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") + .WithMany() + .HasForeignKey("ArtifactId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Artifact"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.HasOne("JobTrackerApi.Models.CvVariant", "CvVariant") + .WithMany() + .HasForeignKey("CvVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CvVariant"); + }); + + 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") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany("Jobs") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.Job", "Job") + .WithMany("Applications") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Company"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Events") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithOne("TailoredCvDraft") + .HasForeignKey("JobTrackerApi.Models.TailoredCvDraft", "JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .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"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Navigation("Attachments"); + + b.Navigation("Events"); + + b.Navigation("Messages"); + + b.Navigation("TailoredCvDraft"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs b/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs new file mode 100644 index 0000000..fa8f832 --- /dev/null +++ b/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddCvVariants : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CvVariants", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + OwnerUserId = table.Column(type: "TEXT", nullable: false), + PublicSlug = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + JobApplicationId = table.Column(type: "INTEGER", nullable: true), + SettingsJson = table.Column(type: "TEXT", nullable: false), + IsPublic = table.Column(type: "INTEGER", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CvVariants", x => x.Id); + table.ForeignKey( + name: "FK_CvVariants_JobApplications_JobApplicationId", + column: x => x.JobApplicationId, + principalTable: "JobApplications", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "CvVariantVersions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + OwnerUserId = table.Column(type: "TEXT", nullable: false), + CvVariantId = table.Column(type: "INTEGER", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false), + SettingsJson = table.Column(type: "TEXT", nullable: false), + Source = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CvVariantVersions", x => x.Id); + table.ForeignKey( + name: "FK_CvVariantVersions_CvVariants_CvVariantId", + column: x => x.CvVariantId, + principalTable: "CvVariants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CvVariants_JobApplicationId", + table: "CvVariants", + column: "JobApplicationId"); + + migrationBuilder.CreateIndex( + name: "IX_CvVariants_OwnerUserId_UpdatedAtUtc", + table: "CvVariants", + columns: new[] { "OwnerUserId", "UpdatedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_CvVariants_PublicSlug", + table: "CvVariants", + column: "PublicSlug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CvVariantVersions_CvVariantId_Version", + table: "CvVariantVersions", + columns: new[] { "CvVariantId", "Version" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CvVariantVersions"); + + migrationBuilder.DropTable( + name: "CvVariants"); + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 2ad490a..e652aee 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -795,6 +795,89 @@ namespace JobTrackerApi.Migrations b.ToTable("CvUploadArtifacts"); }); + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("PublicSlug") + .IsUnique(); + + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + + b.ToTable("CvVariants"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CvVariantId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CvVariantId", "Version"); + + b.ToTable("CvVariantVersions"); + }); + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => { b.Property("Id") @@ -1803,6 +1886,27 @@ namespace JobTrackerApi.Migrations b.Navigation("Artifact"); }); + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.HasOne("JobTrackerApi.Models.CvVariant", "CvVariant") + .WithMany() + .HasForeignKey("CvVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CvVariant"); + }); + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => { b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index b2af0a5..ae0240a 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -36,8 +36,10 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs index d7a80a2..e663e0c 100644 --- a/JobTrackerApi/Services/CareerProfileService.cs +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -30,6 +30,11 @@ public interface ICareerProfileService // 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 RestoreVersionAsync(string ownerUserId, int version, 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 + // backfills — falls back to the ProfileJson blob if relational rows are absent. + Task LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken); } public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent); @@ -120,6 +125,29 @@ public sealed class CareerProfileService : ICareerProfileService return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages); } + public async Task LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken) + { + var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + if (profile is null) return new StructuredCvProfile(); + + var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + + // No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to + // the blob so a public CV still renders. Read-only, so we don't backfill here. + if (experiences.Count == 0 && education.Count == 0 && skills.Count == 0 && projects.Count == 0 + && certifications.Count == 0 && languages.Count == 0 && !string.IsNullOrWhiteSpace(profile.ProfileJson)) + { + return StructuredCvProfileJson.Deserialize(profile.ProfileJson); + } + + return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages); + } + public async Task> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken) { var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs new file mode 100644 index 0000000..f182f1b --- /dev/null +++ b/JobTrackerApi/Services/CvRenderModel.cs @@ -0,0 +1,259 @@ +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +// The resolved, presentation-ready shape a theme renders. Built by CvVariantResolver from the master +// StructuredCvProfile + a variant's CvVariantSettings. This is a projection — it owns no data. +public sealed class CvRenderModel +{ + public string FullName { get; set; } = string.Empty; + public string? Headline { get; set; } + public string? PhotoDataUrl { get; set; } + public List Contact { get; set; } = new(); + public List Sections { get; set; } = new(); +} + +public sealed class CvContactItem +{ + public string Icon { get; set; } = string.Empty; // "email" | "phone" | "location" | "web" | "linkedin" + public string Value { get; set; } = string.Empty; + public string? Href { get; set; } +} + +public sealed class CvRenderSection +{ + public string Key { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + // bullets | tags | entries + public string Kind { get; set; } = "entries"; + public List Bullets { get; set; } = new(); + public List Tags { get; set; } = new(); + public List Entries { get; set; } = new(); + + public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0; +} + +public sealed class CvRenderEntry +{ + public string? Title { get; set; } + public string? Subtitle { get; set; } + public string? Meta { get; set; } + public List Bullets { get; set; } = new(); + public List Tags { get; set; } = new(); +} + +// Turns the master profile + a variant lens into a CvRenderModel. All section order/visibility and +// per-item overrides are applied here; the renderer only draws. Overrides are keyed by ItemKey so +// the master profile is referenced, never copied. +public static class CvVariantResolver +{ + private static readonly string[] DefaultOrder = + { + "summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests", + }; + + public static CvRenderModel Build(StructuredCvProfile profile, CvVariantSettings settings, string fallbackName, string? photoDataUrl) + { + settings = CvVariantSettingsJson.Normalize(settings); + var model = new CvRenderModel + { + FullName = Trim(profile.Contact.FullName) ?? fallbackName, + Headline = Trim(settings.Headline) ?? Trim(profile.Contact.Headline), + PhotoDataUrl = settings.ShowPhoto ? Trim(photoDataUrl) : null, + }; + + AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}"); + AddContact(model.Contact, "phone", profile.Contact.Phone, null); + AddContact(model.Contact, "location", profile.Contact.Location, null); + AddContact(model.Contact, "web", profile.Contact.Website, AsUrl); + AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl); + + var built = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["summary"] = BulletSection("summary", "Professional Summary", profile.Summary), + ["skills"] = TagSection("skills", "Skills", profile.Skills), + ["experience"] = ExperienceSection(profile, settings), + ["education"] = EducationSection(profile, settings), + ["projects"] = ProjectSection(profile, settings), + ["certifications"] = CertificationSection(profile, settings), + ["languages"] = LanguageSection(profile), + ["interests"] = TagSection("interests", "Interests", profile.Interests), + }; + + // OtherSections from the master profile become body sections keyed other:. + for (var i = 0; i < profile.OtherSections.Count; i++) + { + var other = profile.OtherSections[i]; + var key = $"other:{i}"; + built[key] = new CvRenderSection { Key = key, Title = Trim(other.Title) ?? "Additional", Kind = "bullets", Bullets = Clean(other.Items) }; + } + + // Determine order + visibility from settings, falling back to the default order then any extras. + var settingByKey = settings.Sections.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase); + var ordered = settings.Sections.Count > 0 + ? settings.Sections.Select(s => s.Key).ToList() + : DefaultOrder.ToList(); + foreach (var key in built.Keys) + { + if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key); + } + + foreach (var key in ordered) + { + if (key.StartsWith("custom:", StringComparison.OrdinalIgnoreCase)) continue; // handled below + if (!built.TryGetValue(key, out var section)) continue; + if (settingByKey.TryGetValue(key, out var cfg)) + { + if (cfg.Hidden) continue; + if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim(); + } + if (!section.IsEmpty) model.Sections.Add(section); + } + + // Variant-only custom sections, placed by their position in the order list if present. + foreach (var custom in settings.CustomSections) + { + if (custom.Hidden) continue; + var items = Clean(custom.Items); + if (items.Count == 0) continue; + model.Sections.Add(new CvRenderSection + { + Key = $"custom:{custom.Key}", + Title = Trim(custom.Title) ?? "Additional", + Kind = "bullets", + Bullets = items, + }); + } + + return model; + } + + private static CvRenderSection ExperienceSection(StructuredCvProfile profile, CvVariantSettings settings) + { + var section = new CvRenderSection { Key = "experience", Title = "Professional Experience", Kind = "entries" }; + foreach (var job in profile.Jobs) + { + var ov = OverrideFor(settings, job.Id); + if (ov?.Hidden == true) continue; + section.Entries.Add(new CvRenderEntry + { + Title = Trim(ov?.Title) ?? Trim(job.Title), + Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location), + Meta = DateRange(job.Start, job.End, job.IsCurrent), + Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets), + Tags = Clean(job.Skills), + }); + } + return section; + } + + private static CvRenderSection EducationSection(StructuredCvProfile profile, CvVariantSettings settings) + { + var section = new CvRenderSection { Key = "education", Title = "Education", Kind = "entries" }; + foreach (var ed in profile.Education) + { + var ov = OverrideFor(settings, ed.Id); + if (ov?.Hidden == true) continue; + var title = string.IsNullOrWhiteSpace(ed.QualificationLevel) ? Trim(ed.Qualification) : $"{ed.Qualification} ({ed.QualificationLevel})"; + section.Entries.Add(new CvRenderEntry + { + Title = Trim(ov?.Title) ?? title, + Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location), + Meta = DateRange(ed.Start, ed.End, false), + Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details), + }); + } + return section; + } + + private static CvRenderSection ProjectSection(StructuredCvProfile profile, CvVariantSettings settings) + { + var section = new CvRenderSection { Key = "projects", Title = "Projects", Kind = "entries" }; + foreach (var pr in profile.Projects) + { + var ov = OverrideFor(settings, pr.Id); + if (ov?.Hidden == true) continue; + section.Entries.Add(new CvRenderEntry + { + Title = Trim(ov?.Title) ?? Trim(pr.Name), + Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location), + Meta = DateRange(pr.Start, pr.End, false), + Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets), + Tags = Clean(pr.Skills), + }); + } + return section; + } + + private static CvRenderSection CertificationSection(StructuredCvProfile profile, CvVariantSettings settings) + { + var section = new CvRenderSection { Key = "certifications", Title = "Certifications", Kind = "entries" }; + foreach (var c in profile.Certifications) + { + var ov = OverrideFor(settings, c.Id); + if (ov?.Hidden == true) continue; + section.Entries.Add(new CvRenderEntry + { + Title = Trim(ov?.Title) ?? Trim(c.Name), + Subtitle = Trim(ov?.Subtitle) ?? JoinDot(c.Issuer, c.Location), + Meta = Trim(c.Date), + Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(c.Details), + }); + } + return section; + } + + private static CvRenderSection LanguageSection(StructuredCvProfile profile) + { + var section = new CvRenderSection { Key = "languages", Title = "Languages", Kind = "tags" }; + foreach (var l in profile.Languages) + { + var name = Trim(l.Name); + if (name is null) continue; + var level = Trim(l.Level); + section.Tags.Add(level is null ? name : $"{name} — {level}"); + } + return section; + } + + private static CvItemOverride? OverrideFor(CvVariantSettings settings, string? itemKey) + { + if (string.IsNullOrWhiteSpace(itemKey)) return null; + return settings.Overrides.TryGetValue(itemKey, out var ov) ? ov : null; + } + + private static CvRenderSection BulletSection(string key, string title, IEnumerable items) => + new() { Key = key, Title = title, Kind = "bullets", Bullets = Clean(items) }; + + private static CvRenderSection TagSection(string key, string title, IEnumerable items) => + new() { Key = key, Title = title, Kind = "tags", Tags = Clean(items) }; + + private static void AddContact(List list, string icon, string? value, Func? href) + { + var v = Trim(value); + if (v is null) return; + list.Add(new CvContactItem { Icon = icon, Value = v, Href = href?.Invoke(v) }); + } + + private static string AsUrl(string v) => v.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? v : $"https://{v}"; + + private static string? JoinDot(params string?[] parts) + { + var joined = string.Join(" · ", parts.Select(Trim).Where(x => x != null)); + return string.IsNullOrWhiteSpace(joined) ? null : joined; + } + + private static string? DateRange(string? start, string? end, bool isCurrent) + { + var s = Trim(start); + var e = Trim(end); + if (s is null && e is null) return null; + if (s is null) return e; + return $"{s} – {(isCurrent ? "Present" : e ?? "Present")}"; + } + + private static List Clean(IEnumerable? items) => + (items ?? Array.Empty()).Select(x => x?.Trim() ?? string.Empty).Where(x => x.Length > 0).ToList(); + + private static string? Trim(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); +} diff --git a/JobTrackerApi/Services/CvVariantService.cs b/JobTrackerApi/Services/CvVariantService.cs new file mode 100644 index 0000000..a79ccda --- /dev/null +++ b/JobTrackerApi/Services/CvVariantService.cs @@ -0,0 +1,199 @@ +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); + // 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; + 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<(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"); +} diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs new file mode 100644 index 0000000..6ddafd7 --- /dev/null +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -0,0 +1,257 @@ +using System.Globalization; +using System.Net; +using System.Text; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html); + +public interface IThemedCvRenderer +{ + ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings); +} + +// ONE render path for every theme. The theme (data) drives layout, palette, typography and spacing; +// the variant settings apply a few runtime overrides (accent, fonts, density, page size, photo, +// icons, page numbers). Adding a theme never touches this file. docs/architecture/cv-theme-engine.md. +public sealed class ThemedCvRenderer : IThemedCvRenderer +{ + public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings) + { + settings = CvVariantSettingsJson.Normalize(settings); + var accent = Override(settings.AccentColor, theme.Accent); + var headingColor = theme.HeadingColor ?? accent; + var headingFont = Override(settings.HeadingFont, theme.HeadingFont); + var bodyFont = Override(settings.BodyFont, theme.BodyFont); + var density = DensityScale(settings.Density); + var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4"; + var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm"); + var showIcons = settings.ShowIcons && theme.DefaultIcons; + + var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right"; + var (sidebarHtml, mainHtml) = twoColumn + ? SplitColumns(model, theme, showIcons) + : (string.Empty, RenderSections(model.Sections, theme)); + + var css = BuildCss(theme, accent, headingColor, headingFont, bodyFont, density, pageDims, twoColumn); + var header = RenderHeader(model, theme, showIcons, twoColumn); + var body = theme.Layout switch + { + "sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons)}
{mainHtml}
", + "sidebar-right" => $@"
{mainHtml}
{Sidebar(model, sidebarHtml, theme, showIcons)}
", + _ => $@"{header}
{mainHtml}
", + }; + // For two-column themes the header renders inside the sidebar; single/header-band render it on top. + var page = twoColumn ? body : body; + + var html = $@" + + + +{Enc(model.FullName)} — {Enc(theme.Name)} + + + +
{page}
+ +"; + + var fileName = Slug($"{model.FullName}-{theme.Id}") + ".pdf"; + return new ThemedCvRenderResult(theme.Id, fileName, html); + } + + private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons) + { + var sidebarKeys = theme.SidebarSections; + var sidebar = new StringBuilder(); + var mainSections = new List(); + foreach (var section in model.Sections) + { + var baseKey = section.Key.Contains(':') ? section.Key : section.Key; + if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase)) + sidebar.Append(RenderSection(section, theme)); + else + mainSections.Add(section); + } + return (sidebar.ToString(), RenderSections(mainSections, theme)); + } + + private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons) + { + var header = RenderHeader(model, theme, showIcons, twoColumn: true); + var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase) + ? RenderContactBlock(model.Contact, showIcons, sidebar: true) + : string.Empty; + return $@""; + } + + private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn) + { + var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape); + var kicker = theme.HeaderStyle == "kicker" ? @"
Curriculum Vitae
" : string.Empty; + var name = $@"

{Enc(model.FullName)}

"; + var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"
{Enc(model.Headline)}
"; + var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false); + var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}"; + return $@"
{photo}
{kicker}{name}{headline}{contact}
"; + } + + private static string RenderPhoto(string? dataUrl, string shape) + { + if (shape == "none" || string.IsNullOrWhiteSpace(dataUrl)) return string.Empty; + return $@"
"; + } + + private static string RenderContactBlock(List contact, bool showIcons, bool sidebar) + { + if (contact.Count == 0) return string.Empty; + var items = new StringBuilder(); + foreach (var c in contact) + { + var icon = showIcons ? Icon(c.Icon) : string.Empty; + var text = c.Href is null ? Enc(c.Value) : $@"{Enc(c.Value)}"; + items.Append($@"{icon}{text}"); + } + return $@"
{items}
"; + } + + private static string RenderSections(IEnumerable sections, CvTheme theme) + { + var sb = new StringBuilder(); + foreach (var section in sections) sb.Append(RenderSection(section, theme)); + return sb.ToString(); + } + + private static string RenderSection(CvRenderSection section, CvTheme theme) + { + if (section.IsEmpty) return string.Empty; + var inner = section.Kind switch + { + "bullets" => $@"
    {Items(section.Bullets)}
", + "tags" => $@"
    {string.Join("", section.Tags.Select(t => $@"
  • {Enc(t)}
  • "))}
", + _ => string.Join("", section.Entries.Select(RenderEntry)), + }; + return $@"

{Enc(section.Title)}

{inner}
"; + } + + private static string RenderEntry(CvRenderEntry entry) + { + var sb = new StringBuilder(); + sb.Append(@"
"); + var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta); + sb.Append(@"
"); + sb.Append($@"
{Enc(entry.Title)}
"); + if (hasMeta) sb.Append($@"
{Enc(entry.Meta)}
"); + sb.Append("
"); + if (!string.IsNullOrWhiteSpace(entry.Subtitle)) sb.Append($@"
{Enc(entry.Subtitle)}
"); + if (entry.Bullets.Count > 0) sb.Append($@"
    {Items(entry.Bullets)}
"); + if (entry.Tags.Count > 0) sb.Append($@"
    {string.Join("", entry.Tags.Select(t => $@"
  • {Enc(t)}
  • "))}
"); + sb.Append("
"); + return sb.ToString(); + } + + private static string Items(IEnumerable items) => + string.Join("", items.Select(i => $"
  • {Enc(i)}
  • ")); + + private static string BuildCss(CvTheme t, string accent, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn) + { + var margin = F(t.PageMarginMm * density); + var sectionGap = F(t.SectionGapMm * density); + var entryGap = F(t.EntryGapMm * density); + var headingCss = t.HeadingStyle switch + { + "underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}", + "plain" => ".section-title{letter-spacing:.01em;}", + "bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}", + _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}", + }; + var layoutCss = twoColumn + ? $@".cols{{display:grid;grid-template-columns:{(t.Layout == "sidebar-right" ? $"1fr {F(t.SidebarWidthMm)}mm" : $"{F(t.SidebarWidthMm)}mm 1fr")};min-height:{page.h};}} +.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}} +.sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}} +.sidebar .tag{{border-color:rgba(255,255,255,.4);}} +.sidebar a{{color:inherit;}} +.main{{padding:{margin}mm;}} +.hero .name{{color:{t.SidebarInk};}}" + : $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}} +.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}} +.header-band{{background:{accent};color:#fff;}} +.header-band .name,.header-band .headline,.header-band a{{color:#fff;}} +.header-centered{{flex-direction:column;text-align:center;justify-content:center;}} +.header-centered .contact{{justify-content:center;}} +.header-plain{{border-bottom:2px solid {accent};}}"; + + return $@" +*{{box-sizing:border-box;}} +body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} +.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:hidden;}} +h1,h2{{font-family:{headingFont};}} +.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;}} +.kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}} +.headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}} +.head-text{{flex:1;}} +.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}} +.photo-square{{border-radius:2mm;}} +.photo-rounded{{border-radius:5mm;}} +.photo-circle{{border-radius:50%;}} +.photo img{{width:100%;height:100%;object-fit:cover;display:block;}} +.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}} +.contact-stacked{{flex-direction:column;gap:1.8mm;}} +.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;}} +.contact a{{color:inherit;text-decoration:none;}} +.contact svg{{width:3.2mm;height:3.2mm;flex:0 0 auto;opacity:.85;}} +.hero{{margin-bottom:{sectionGap}mm;}} +.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}} +.section{{margin-top:{sectionGap}mm;}} +.section:first-child{{margin-top:0;}} +.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}} +{headingCss} +.bullets{{margin:0;padding-left:4.5mm;}} +.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}} +.tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}} +.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;}} +.entry{{margin-bottom:{entryGap}mm;}} +.entry:last-child{{margin-bottom:0;}} +.entry-head{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;}} +.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;}} +.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:nowrap;}} +.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}} +.entry-tags{{margin-top:1.4mm;}} +{layoutCss} +@page{{size:{page.w} {page.h};margin:0;}} +"; + } + + private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch + { + "compact" => 0.82, + "roomy" => 1.18, + _ => 1.0, + }; + + private static string Icon(string kind) => kind switch + { + "email" => Svg("M2 4h12v8H2z M2 4l6 4 6-4"), + "phone" => Svg("M3 3c0 5 5 10 10 10l1-3-3-1-1 1c-2-1-4-3-5-5l1-1-1-3z"), + "location" => Svg("M8 1a4 4 0 0 0-4 4c0 3 4 8 4 8s4-5 4-8a4 4 0 0 0-4-4z M8 5v.01"), + "web" => Svg("M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z M1 8h14 M8 1c2 2 2 12 0 14 M8 1c-2 2-2 12 0 14"), + "linkedin" => Svg("M3 6v7 M3 3v.01 M7 13V6 M7 9c0-3 5-3 5 0v4"), + _ => string.Empty, + }; + + private static string Svg(string path) => + $@""; + + private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + private static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture); + private static string Enc(string? v) => WebUtility.HtmlEncode(v ?? string.Empty); + private static string Attr(string? v) => WebUtility.HtmlEncode(v ?? string.Empty).Replace("'", "'", StringComparison.Ordinal); + + private static string Slug(string v) + { + var cleaned = new string((v ?? string.Empty).ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()); + while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal); + return cleaned.Trim('-'); + } +} diff --git a/Models/CvTheme.cs b/Models/CvTheme.cs new file mode 100644 index 0000000..9592c27 --- /dev/null +++ b/Models/CvTheme.cs @@ -0,0 +1,145 @@ +namespace JobTrackerApi.Models; + +// A theme is DATA, not code. The renderer (ThemedCvRenderer) has one render path; every visual +// difference between themes is expressed by the fields below. Adding a theme = adding one CvTheme to +// CvThemeCatalog — no renderer changes. This is the core Phase 4 architectural rule +// ("Do NOT hardcode HTML templates"). docs/architecture/cv-theme-engine.md. +public sealed class CvTheme +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Category { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + + // single | sidebar-left | sidebar-right | header-band + public string Layout { get; init; } = "single"; + public double SidebarWidthMm { get; init; } = 62; + + // Palette + public string Accent { get; init; } = "#2563eb"; + public string Ink { get; init; } = "#111827"; + public string Muted { get; init; } = "#4b5563"; + public string Line { get; init; } = "#d1d5db"; + public string Paper { get; init; } = "#ffffff"; + public string SidebarBg { get; init; } = "#f3f4f6"; + public string SidebarInk { get; init; } = "#111827"; + public string? HeadingColor { get; init; } // null => accent + + // Typography + public string HeadingFont { get; init; } = "Georgia, 'Times New Roman', serif"; + public string BodyFont { get; init; } = "Arial, Helvetica, sans-serif"; + public double NameSizePt { get; init; } = 24; + public double HeadingSizePt { get; init; } = 12; + public double BodySizePt { get; init; } = 10; + public double LineHeight { get; init; } = 1.42; + + // Spacing (mm) + public double PageMarginMm { get; init; } = 16; + public double SectionGapMm { get; init; } = 6; + public double EntryGapMm { get; init; } = 4.5; + + // plain | band | centered | kicker + public string HeaderStyle { get; init; } = "plain"; + // caps-rule | underline | plain | bar + public string HeadingStyle { get; init; } = "caps-rule"; + // none | square | rounded | circle + public string PhotoShape { get; init; } = "rounded"; + + public bool DefaultIcons { get; init; } + + // Which section keys render in the sidebar for two-column layouts (ignored for single/header-band). + public List SidebarSections { get; init; } = new() { "contact", "skills", "languages" }; +} + +public static class CvThemeCatalog +{ + // 8 professional themes, all data. To add one: append here. + public static readonly IReadOnlyList Themes = new List + { + new() + { + Id = "modern", Name = "Modern", Category = "Modern Professional", + Description = "Clean SaaS-style layout with a confident accent header.", + Layout = "header-band", HeaderStyle = "band", HeadingStyle = "caps-rule", + Accent = "#2563eb", HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif", + PhotoShape = "rounded", DefaultIcons = true, + }, + new() + { + Id = "minimal", Name = "Minimal", Category = "Minimal", + Description = "Quiet, spacious, typography-first. Nothing competes with the content.", + Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain", + Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280", + HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif", + SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", + }, + new() + { + Id = "executive", Name = "Executive", Category = "Executive", + Description = "High-contrast, serif, leadership-weighted. For senior and client-facing roles.", + Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline", + Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917", + HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif", + NameSizePt = 27, PhotoShape = "square", + }, + new() + { + Id = "technical", Name = "Technical", Category = "Technical", + Description = "Dense two-column layout tuned for engineering CVs — skills and projects up front.", + Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", + Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff", + HeadingFont = "'Roboto', Arial, sans-serif", BodyFont = "'Roboto', Arial, sans-serif", + BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true, + SidebarSections = new() { "contact", "skills", "languages", "certifications" }, + }, + new() + { + Id = "ats-classic", Name = "ATS Classic", Category = "ATS Professional", + Description = "Single column, no graphics, maximum parser compatibility. The safe default.", + Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule", + Accent = "#334155", Ink = "#111827", Muted = "#374151", + HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif", + PhotoShape = "none", + }, + new() + { + Id = "nordic", Name = "Nordic", Category = "Modern Professional", + Description = "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.", + Layout = "sidebar-right", HeaderStyle = "plain", HeadingStyle = "caps-rule", + Accent = "#3b6ea5", Ink = "#1f2937", Muted = "#4b5563", SidebarBg = "#eef3f8", SidebarInk = "#1f2937", + HeadingFont = "'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif", + PhotoShape = "circle", DefaultIcons = true, + }, + new() + { + Id = "elegant", Name = "Elegant", Category = "Creative", + Description = "Editorial serif headings over sans body, premium spacing and a plum accent.", + Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline", + Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563", + HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "'Segoe UI', Arial, sans-serif", + NameSizePt = 26, SectionGapMm = 7, PhotoShape = "circle", + }, + new() + { + Id = "creative", Name = "Creative", Category = "Creative", + Description = "Bold accent sidebar and photo-forward header for design and product roles.", + Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", + Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#db2777", SidebarInk = "#ffffff", + HeadingFont = "'Poppins', 'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif", + PhotoShape = "circle", DefaultIcons = true, + SidebarSections = new() { "contact", "skills", "languages", "interests" }, + }, + }; + + public static CvTheme Resolve(string? id) + { + var key = (id ?? string.Empty).Trim().ToLowerInvariant(); + return Themes.FirstOrDefault(t => t.Id == key) ?? Themes[0]; + } + + public static bool Exists(string? id) + { + var key = (id ?? string.Empty).Trim().ToLowerInvariant(); + return Themes.Any(t => t.Id == key); + } +} diff --git a/Models/CvVariant.cs b/Models/CvVariant.cs new file mode 100644 index 0000000..000ba31 --- /dev/null +++ b/Models/CvVariant.cs @@ -0,0 +1,53 @@ +namespace JobTrackerApi.Models; + +// Phase 4 — Career Workspace Builder. +// +// A CvVariant is NOT another career profile. It is a *lens* over the master CareerProfile: which +// sections show, in what order, with what per-item overrides, rendered through a chosen theme with +// layout/colour/font settings. The career data is never duplicated here — the variant stores only +// selections + overrides keyed by the master profile's stable ItemKeys, plus builder preferences. +// docs/architecture/cv-builder.md. +public sealed class CvVariant +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + + // Public share slug for /cv/{slug}. Assigned at creation, stable, unguessable. + public string PublicSlug { get; set; } = string.Empty; + + public string Name { get; set; } = "Untitled CV"; + + // Optional link to the job application this variant was tailored for. Reference only — deleting + // the application must not delete the variant (SetNull), and the variant never owns job data. + public int? JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + + // Serialized CvVariantSettings — the whole lens (theme, layout, section order/visibility, + // per-item overrides, builder prefs). One blob because it is edited and saved as a unit and + // never queried field-by-field, exactly like a version snapshot. + public string SettingsJson { get; set; } = string.Empty; + + // Public visibility. Off by default; a public variant is served read-only at /cv/{PublicSlug} + // and is marked noindex. + public bool IsPublic { get; set; } + + // Autosave version counter (mirrors CareerProfile.Version). Incremented on each saved snapshot. + public int Version { get; set; } + + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } +} + +// Append-only autosave history for a variant, so builder edits are reversible. Mirrors +// CareerProfileVersion. Restore re-saves an old snapshot as a new version (non-destructive). +public sealed class CvVariantVersion +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int CvVariantId { get; set; } + public CvVariant? CvVariant { get; set; } + public int Version { get; set; } + public string SettingsJson { get; set; } = string.Empty; + public string Source { get; set; } = "autosave"; // autosave | manual | restore | create + public DateTimeOffset CreatedAtUtc { get; set; } +} diff --git a/Models/CvVariantSettings.cs b/Models/CvVariantSettings.cs new file mode 100644 index 0000000..e0cc4cf --- /dev/null +++ b/Models/CvVariantSettings.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JobTrackerApi.Models; + +// The "lens" a CvVariant stores. Deliberately all-optional + defaulted so an empty settings object +// renders a sensible default CV straight from the master profile. Overrides are keyed by the master +// profile's stable ItemKeys so career data is referenced, never copied. +public sealed class CvVariantSettings +{ + public string ThemeId { get; set; } = "modern"; + + // Theme overrides (null => use the theme's own value). + public string? AccentColor { get; set; } + public string? HeadingFont { get; set; } + public string? BodyFont { get; set; } + public string? Density { get; set; } // compact | balanced | roomy + public string? PageSize { get; set; } // a4 | letter + public string? DateFormat { get; set; } // display hint for the frontend; renderer uses source strings + public string? Language { get; set; } + public string? Headline { get; set; } // override the contact headline for this variant + + public bool ShowPhoto { get; set; } + public bool ShowPageNumbers { get; set; } + public bool ShowIcons { get; set; } = true; + + // Section order + visibility + optional renamed heading. Empty => default order, all visible. + public List Sections { get; set; } = new(); + + // Per-item overrides keyed by ItemKey (job/education/project/certification stable id). + public Dictionary Overrides { get; set; } = new(); + + // Extra sections unique to this variant (not in the master profile). + public List CustomSections { get; set; } = new(); +} + +public sealed class CvSectionSetting +{ + public string Key { get; set; } = string.Empty; // summary|skills|experience|education|projects|certifications|languages|interests|links|custom: + public bool Hidden { get; set; } + public string? Title { get; set; } // renamed heading +} + +public sealed class CvItemOverride +{ + public bool Hidden { get; set; } + public string? Title { get; set; } + public string? Subtitle { get; set; } + public List? Bullets { get; set; } // null => use master bullets; non-null => replace for this variant only +} + +public sealed class CvCustomSectionSetting +{ + public string Key { get; set; } = string.Empty; + public string? Title { get; set; } + public List Items { get; set; } = new(); + public bool Hidden { get; set; } +} + +public static class CvVariantSettingsJson +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string Serialize(CvVariantSettings? settings) => + JsonSerializer.Serialize(Normalize(settings), Options); + + public static CvVariantSettings Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new CvVariantSettings(); + try { return Normalize(JsonSerializer.Deserialize(json, Options)); } + catch { return new CvVariantSettings(); } + } + + public static CvVariantSettings Normalize(CvVariantSettings? s) + { + s ??= new CvVariantSettings(); + s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant(); + s.Sections ??= new(); + s.Overrides ??= new(); + s.CustomSections ??= new(); + return s; + } +}