diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 0867d94..450664a 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -46,6 +46,7 @@ namespace JobTrackerApi.Data public DbSet CvVariantVersions => Set(); public DbSet AiInteractions => Set(); public DbSet ApplicationChecklistItems => Set(); + public DbSet CoverLetterVersions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -366,6 +367,23 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // Phase 5.4: append-only cover letter history. JobApplication.CoverLetterText stays the + // current text; this makes every previous state recoverable. + // docs/architecture/application-workspace.md. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + // varchar (not longtext) for the indexed columns — see the CvVariant note above. + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.Source).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.AiAction).HasMaxLength(32); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Version }); + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); } // Common config for CareerProfile's relational children. The 1:many FK + cascade delete is diff --git a/JobTrackerApi.Tests/ApplicationAssetsTests.cs b/JobTrackerApi.Tests/ApplicationAssetsTests.cs new file mode 100644 index 0000000..241bf23 --- /dev/null +++ b/JobTrackerApi.Tests/ApplicationAssetsTests.cs @@ -0,0 +1,308 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Phase 5.4 — Application Assets. The properties under test are the architecture rules: the flow runs +// CareerProfile -> CvVariant -> application output and never back, versions are never destroyed, and +// nothing is reachable across tenants. +public sealed class ApplicationAssetsTests +{ + private static (JobTrackerContext db, ApplicationAssetsService assets) New(string userId) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + + // Only ListAsync is used here — attaching, creating, previewing and exporting all stay in + // CvVariantService, which has its own tests. Mocking it keeps these tests about association. + var variants = new Mock(); + variants + .Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string owner, CancellationToken _) => db.CvVariants + .Where(v => v.OwnerUserId == owner) + .Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc)) + .ToList()); + + var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService()); + return (db, new ApplicationAssetsService(db, variants.Object, intelligence)); + } + + private static async Task SeedJobAsync(JobTrackerContext db, string owner) + { + var company = new Company { OwnerUserId = owner, Name = "Acme" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication + { + OwnerUserId = owner, + CompanyId = company.Id, + JobTitle = "Senior Backend Developer", + Status = "Applied", + Description = "We need strong C# and .NET experience, plus SQL and Docker. You will build REST APIs.", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static async Task SeedVariantAsync(JobTrackerContext db, string owner, string name, int? jobId = null) + { + var variant = new CvVariant + { + OwnerUserId = owner, + Name = name, + JobApplicationId = jobId, + PublicSlug = Guid.NewGuid().ToString("N"), + SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }), + CreatedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + }; + db.CvVariants.Add(variant); + await db.SaveChangesAsync(); + return variant; + } + + // ---------- Part 1: CV variant association ---------- + + [Fact] + public async Task Attaching_a_variant_points_the_application_at_it() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + var variant = await SeedVariantAsync(db, "user-1", "Backend CV"); + + var cv = await assets.AttachVariantAsync("user-1", job.Id, variant.Id, default); + + Assert.Equal(variant.Id, cv!.AttachedVariantId); + Assert.Equal("Backend CV", cv.AttachedVariantName); + Assert.Equal("nordic", cv.AttachedThemeId); + } + + [Fact] + public async Task Attaching_a_second_variant_replaces_the_first_rather_than_stacking() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + var first = await SeedVariantAsync(db, "user-1", "First"); + var second = await SeedVariantAsync(db, "user-1", "Second"); + + await assets.AttachVariantAsync("user-1", job.Id, first.Id, default); + var cv = await assets.AttachVariantAsync("user-1", job.Id, second.Id, default); + + Assert.Equal(second.Id, cv!.AttachedVariantId); + // The replaced variant is detached, not deleted — it is still the user's to reuse. + var stillThere = await db.CvVariants.FirstAsync(v => v.Id == first.Id); + Assert.Null(stillThere.JobApplicationId); + } + + [Fact] + public async Task Detaching_leaves_the_variant_intact() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + var variant = await SeedVariantAsync(db, "user-1", "Backend CV", null); + await assets.AttachVariantAsync("user-1", job.Id, variant.Id, default); + + var cv = await assets.AttachVariantAsync("user-1", job.Id, null, default); + + Assert.Null(cv!.AttachedVariantId); + Assert.Equal(1, await db.CvVariants.CountAsync()); + } + + [Fact] + public async Task Another_users_variant_cannot_be_attached() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + var theirs = await SeedVariantAsync(db, "user-2", "Not yours"); + + Assert.Null(await assets.AttachVariantAsync("user-1", job.Id, theirs.Id, default)); + } + + [Fact] + public async Task Cv_section_is_not_readable_for_another_users_application() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await assets.GetCvAsync("user-1", other.Id, default)); + } + + // ---------- Part 2: tailoring ---------- + + [Fact] + public async Task Tailoring_suggests_keywords_even_without_a_career_profile() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + var plan = await assets.GetTailoringPlanAsync("user-1", job.Id, default); + + Assert.NotNull(plan); + Assert.False(plan!.HasCareerProfile); + Assert.Contains(plan.Suggestions, s => s.Kind == "include-keywords"); + } + + [Fact] + public async Task Tailoring_never_writes_to_the_career_profile_or_the_variant() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + var variant = await SeedVariantAsync(db, "user-1", "Backend CV", job.Id); + db.CareerProfiles.Add(new CareerProfile + { + OwnerUserId = "user-1", + Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", Company = "Initech", BulletsJson = """["C# and .NET"]""" } }, + }); + await db.SaveChangesAsync(); + var settingsBefore = variant.SettingsJson; + var versionBefore = variant.Version; + var profileVersionBefore = (await db.CareerProfiles.FirstAsync()).Version; + + var plan = await assets.GetTailoringPlanAsync("user-1", job.Id, default); + + Assert.NotEmpty(plan!.Suggestions); + var variantAfter = await db.CvVariants.AsNoTracking().FirstAsync(v => v.Id == variant.Id); + Assert.Equal(settingsBefore, variantAfter.SettingsJson); + Assert.Equal(versionBefore, variantAfter.Version); + Assert.Equal(profileVersionBefore, (await db.CareerProfiles.AsNoTracking().FirstAsync()).Version); + } + + [Fact] + public async Task Tailoring_is_not_readable_for_another_users_application() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await assets.GetTailoringPlanAsync("user-1", other.Id, default)); + } + + // ---------- Part 3: cover letter ---------- + + [Fact] + public async Task Saving_a_cover_letter_stores_a_version_and_sets_the_current_text() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, default); + + Assert.Equal("Dear team", result!.Text); + Assert.Equal(1, result.CurrentVersion); + Assert.Single(result.Versions); + Assert.True(result.Versions[0].IsCurrent); + Assert.Equal("Dear team", (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).CoverLetterText); + } + + [Fact] + public async Task Each_edit_adds_a_version_and_none_are_lost() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, default); + await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", default); + var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, default); + + Assert.Equal(3, result!.CurrentVersion); + Assert.Equal(3, result.Versions.Count); + Assert.Equal("improve", result.Versions.Single(v => v.Version == 2).AiAction); + Assert.Equal(CoverLetterSources.Ai, result.Versions.Single(v => v.Version == 2).Source); + } + + [Fact] + public async Task An_unchanged_save_does_not_burn_a_version() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default); + var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default); + + Assert.Single(result!.Versions); + } + + [Fact] + public async Task Restoring_brings_back_old_text_without_destroying_the_newer_version() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, default); + await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", default); + + var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, default); + + Assert.Equal("The good draft", result!.Text); + // Restore is additive: v1 and v2 both survive, and the restore is v3. + Assert.Equal(3, result.CurrentVersion); + Assert.Equal(3, result.Versions.Count); + Assert.Equal(CoverLetterSources.Restore, result.Versions.Single(v => v.Version == 3).Source); + Assert.Equal("The AI ruined it", (await db.CoverLetterVersions.AsNoTracking().FirstAsync(v => v.Version == 2)).Text); + } + + [Fact] + public async Task Restoring_a_version_that_does_not_exist_is_a_miss() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, default); + + Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, default)); + } + + [Fact] + public async Task Cover_letter_history_is_scoped_to_its_owner() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var mine = await SeedJobAsync(db, "user-1"); + var theirs = await SeedJobAsync(db, "user-2"); + await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, default); + + Assert.Null(await assets.GetCoverLetterAsync("user-1", theirs.Id, default)); + Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, default)); + Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, default)); + } + + [Fact] + public async Task An_ai_draft_only_becomes_a_version_when_the_user_saves_it() + { + var (db, assets) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + // An AI generation on its own is an AiInteraction — history, not the user's cover letter. + db.AiInteractions.Add(new AiInteraction + { + OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "cover-letter", + Title = "Cover letter", Provider = "p", ResultJson = """{"text":"AI suggestion"}""", + CreatedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var before = await assets.GetCoverLetterAsync("user-1", job.Id, default); + + Assert.Null(before!.Text); + Assert.Empty(before.Versions); + Assert.Equal(1, before.AiSuggestionCount); + } +} diff --git a/JobTrackerApi/Controllers/ApplicationAssetsController.cs b/JobTrackerApi/Controllers/ApplicationAssetsController.cs new file mode 100644 index 0000000..323c5b6 --- /dev/null +++ b/JobTrackerApi/Controllers/ApplicationAssetsController.cs @@ -0,0 +1,89 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Phase 5.4 — Application Assets. Connects existing career outputs to one application. +// +// Deliberately thin: CV variant CRUD, preview, PDF export, themes and version history all stay on +// /api/cv (CvVariantController), and documents stay on /api/attachments. These routes only cover what +// is genuinely application-scoped — which variant this application uses, what to tailor, and the +// cover letter with its history. Every route is tenant-scoped and returns 404 for another user's +// application. docs/architecture/application-workspace.md. +[ApiController] +[Route("api/jobapplications/{jobId:int}")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class ApplicationAssetsController : ControllerBase +{ + public sealed record AttachVariantRequest(int? VariantId); + public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction); + + private readonly UserManager _users; + private readonly IApplicationAssetsService _assets; + + public ApplicationAssetsController(UserManager users, IApplicationAssetsService assets) + { + _users = users; + _assets = assets; + } + + [HttpGet("cv")] + public async Task> GetCv(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _assets.GetCvAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("cv")] + public async Task> AttachVariant(int jobId, [FromBody] AttachVariantRequest request, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _assets.AttachVariantAsync(userId, jobId, request?.VariantId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("tailoring")] + public async Task> GetTailoring(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _assets.GetTailoringPlanAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("cover-letter")] + public async Task> GetCoverLetter(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _assets.GetCoverLetterAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("cover-letter")] + public async Task> SaveCoverLetter(int jobId, [FromBody] SaveCoverLetterRequest request, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _assets.SaveCoverLetterAsync( + userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("cover-letter/versions/{version:int}/restore")] + public async Task> RestoreCoverLetter(int jobId, int version, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, ct); + return result is null ? NotFound() : Ok(result); + } + + private async Task CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; +} diff --git a/JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.Designer.cs b/JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.Designer.cs new file mode 100644 index 0000000..0e03d3e --- /dev/null +++ b/JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.Designer.cs @@ -0,0 +1,2272 @@ +// +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("20260719120954_AddCoverLetterVersions")] + partial class AddCoverLetterVersions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("TEXT"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc"); + + b.ToTable("AiInteractions"); + }); + + 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.ApplicationChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoSignal") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAutoCompleted") + .HasColumnType("INTEGER"); + + b.Property("IsSystemGenerated") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Section") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SystemKey") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId", "SystemKey") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("ApplicationChecklistItems"); + }); + + 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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .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.CoverLetterVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AiAction") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Version"); + + b.ToTable("CoverLetterVersions"); + }); + + 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() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasMaxLength(64) + .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() + .HasMaxLength(255) + .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.AiInteraction", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + 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.ApplicationChecklistItem", 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.CoverLetterVersion", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .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/20260719120954_AddCoverLetterVersions.cs b/JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.cs new file mode 100644 index 0000000..20ed56f --- /dev/null +++ b/JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddCoverLetterVersions : Migration + { + // Deliberately a no-op. Scaffolded against SQLite, so on MariaDB it would emit a TEXT + // CreatedAtUtc and a PRIMARY KEY without AUTO_INCREMENT, and the composite index over those + // columns would exceed MySQL's 3072-byte key limit. + // + // CoverLetterVersions is reconciler-owned and provisioned by StartupInitializationExtensions, + // which carries correct DDL per provider and guards the create on JobApplications existing. + // This migration exists only to keep the model snapshot in sync. + // docs/infrastructure/database-ownership.md. + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 132234c..43aeb92 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -819,6 +819,48 @@ namespace JobTrackerApi.Migrations b.ToTable("Correspondences"); }); + modelBuilder.Entity("JobTrackerApi.Models.CoverLetterVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AiAction") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Version"); + + b.ToTable("CoverLetterVersions"); + }); + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => { b.Property("Id") @@ -2031,6 +2073,17 @@ namespace JobTrackerApi.Migrations b.Navigation("JobApplication"); }); + modelBuilder.Entity("JobTrackerApi.Models.CoverLetterVersion", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => { b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index b94aa4c..ba8865c 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/ApplicationAssetsService.cs b/JobTrackerApi/Services/ApplicationAssetsService.cs new file mode 100644 index 0000000..e1deeac --- /dev/null +++ b/JobTrackerApi/Services/ApplicationAssetsService.cs @@ -0,0 +1,299 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// Phase 5.4 — Application Assets. +// +// Connects the career outputs the user already has to one job application. It owns almost nothing: +// CV variants stay in CvVariantService (a lens over the master CareerProfile), documents stay in +// Attachment, AI narrative stays in AiWorkspaceService. The only new state is the append-only cover +// letter history, because JobApplication.CoverLetterText had no way back from a bad rewrite. +// +// The flow is strictly one-directional — CareerProfile -> CvVariant -> application output. Nothing +// here writes upward: no method touches CareerProfile or its children. +// docs/architecture/application-workspace.md. +public sealed record ApplicationCvDto( + int? AttachedVariantId, + string? AttachedVariantName, + string? AttachedThemeId, + int? AttachedVersion, + DateTimeOffset? AttachedUpdatedAtUtc, + bool AttachedIsPublic, + bool HasTailoredCvText, + IReadOnlyList AvailableVariants); + +public sealed record TailoringSuggestionDto(string Kind, string Title, string? Detail, IReadOnlyList Items); + +public sealed record TailoringPlanDto( + bool HasJobDescription, + bool HasCareerProfile, + bool HasAttachedVariant, + int MatchScore, + IReadOnlyList Suggestions, + int AiSuggestionCount); + +public sealed record CoverLetterVersionDto(int Version, string Source, string? AiAction, int Length, DateTimeOffset CreatedAtUtc, bool IsCurrent); + +public sealed record CoverLetterDto( + string? Text, + int CurrentVersion, + IReadOnlyList Versions, + int AiSuggestionCount); + +public interface IApplicationAssetsService +{ + Task GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task AttachVariantAsync(string ownerUserId, int jobApplicationId, int? variantId, CancellationToken ct); + Task GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + + Task GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct); + Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct); +} + +public sealed class ApplicationAssetsService : IApplicationAssetsService +{ + private readonly JobTrackerContext _db; + private readonly ICvVariantService _variants; + private readonly IApplicationIntelligenceService _intelligence; + + public ApplicationAssetsService(JobTrackerContext db, ICvVariantService variants, IApplicationIntelligenceService intelligence) + { + _db = db; + _variants = variants; + _intelligence = intelligence; + } + + // ---------- Part 1: CV variant integration ---------- + + public async Task GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + return await BuildCvAsync(ownerUserId, job, ct); + } + + // Attach an EXISTING variant to this application, or detach with null. Creating, duplicating, + // editing, previewing and exporting all stay in CvVariantService — this only moves the pointer. + public async Task AttachVariantAsync(string ownerUserId, int jobApplicationId, int? variantId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + + // Detaching clears whatever this application currently points at. + var currentlyAttached = await _db.CvVariants + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId) + .ToListAsync(ct); + + if (variantId is null) + { + foreach (var v in currentlyAttached) v.JobApplicationId = null; + await _db.SaveChangesAsync(ct); + return await BuildCvAsync(ownerUserId, job, ct); + } + + var target = await _db.CvVariants + .FirstOrDefaultAsync(v => v.Id == variantId.Value && v.OwnerUserId == ownerUserId, ct); + if (target is null) return null; + + // One attached variant per application: the workspace answers "which CV am I sending". + foreach (var v in currentlyAttached.Where(v => v.Id != target.Id)) v.JobApplicationId = null; + target.JobApplicationId = jobApplicationId; + target.UpdatedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(ct); + + return await BuildCvAsync(ownerUserId, job, ct); + } + + private async Task BuildCvAsync(string ownerUserId, JobApplication job, CancellationToken ct) + { + var attached = await _db.CvVariants.AsNoTracking() + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id) + .OrderByDescending(v => v.UpdatedAtUtc) + .FirstOrDefaultAsync(ct); + + var available = await _variants.ListAsync(ownerUserId, ct); + + return new ApplicationCvDto( + attached?.Id, + attached?.Name, + attached is null ? null : CvVariantSettingsJson.Deserialize(attached.SettingsJson).ThemeId, + attached?.Version, + attached?.UpdatedAtUtc, + attached?.IsPublic ?? false, + !string.IsNullOrWhiteSpace(job.TailoredCvText), + available); + } + + // ---------- Part 2: tailoring workflow ---------- + + // Deterministic suggestions built from the Phase 5.3 analysis and match. These are SUGGESTIONS: + // the service returns what the user could emphasise and the user decides. Nothing here edits a + // variant, and nothing writes to the CareerProfile. + public async Task GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + + var analysis = await _intelligence.AnalyzeAsync(ownerUserId, jobApplicationId, ct); + var match = await _intelligence.MatchAsync(ownerUserId, jobApplicationId, ct); + if (analysis is null || match is null) return null; + + var attachedVariantId = await _db.CvVariants.AsNoTracking() + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId) + .Select(v => (int?)v.Id) + .FirstOrDefaultAsync(ct); + + var suggestions = new List(); + + if (match.MatchedSkills.Count > 0) + { + suggestions.Add(new TailoringSuggestionDto( + "highlight-skills", + "Skills to highlight", + "The advert asks for these and your profile already has them — put them where they are seen first.", + match.MatchedSkills)); + } + + if (match.RelevantExperience.Count > 0) + { + suggestions.Add(new TailoringSuggestionDto( + "prioritise-experience", + "Experience to prioritise", + "Ordered by how much of the advert each role actually covers.", + match.RelevantExperience.Select(e => e.Subtitle is null ? e.Title : $"{e.Title} — {e.Subtitle}").ToList())); + } + + if (match.RelevantProjects.Count > 0) + { + suggestions.Add(new TailoringSuggestionDto( + "emphasise-projects", + "Projects to emphasise", + "These projects demonstrate what the advert is asking for.", + match.RelevantProjects.Select(p => p.Subtitle is null ? p.Title : $"{p.Title} — {p.Subtitle}").ToList())); + } + + if (analysis.Keywords.Count > 0) + { + suggestions.Add(new TailoringSuggestionDto( + "include-keywords", + "Keywords to include", + "Vocabulary from the advert. Use the ones that are honestly true of you — never pad.", + analysis.Keywords)); + } + + if (match.MissingSkills.Count > 0) + { + suggestions.Add(new TailoringSuggestionDto( + "gaps", + "Gaps to address", + "Asked for but not found in your profile. Add them if you have them; otherwise be ready to talk about them.", + match.MissingSkills)); + } + + return new TailoringPlanDto( + analysis.HasJobDescription, + match.HasCareerProfile, + attachedVariantId is not null, + match.Score, + suggestions, + analysis.AiSuggestionCount + match.AiSuggestionCount); + } + + // ---------- Part 3: cover letter workflow ---------- + + public async Task GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + return await BuildCoverLetterAsync(ownerUserId, job, ct); + } + + // Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's + // text is what gets stored — an AI suggestion only becomes a version once the user saves it, which + // is what "requires approval" means here. + public async Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct) + { + var job = await _db.JobApplications + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + var next = text?.Trim() ?? string.Empty; + var current = job.CoverLetterText?.Trim() ?? string.Empty; + + // Nothing changed: do not spend a version on a no-op save (autosave calls this often). + if (string.Equals(next, current, StringComparison.Ordinal)) + { + return await BuildCoverLetterAsync(ownerUserId, job, ct); + } + + var version = await NextVersionAsync(ownerUserId, jobApplicationId, ct); + _db.CoverLetterVersions.Add(new CoverLetterVersion + { + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Version = version, + Text = next, + Source = CoverLetterSources.IsValid(source) ? source : CoverLetterSources.Manual, + AiAction = string.IsNullOrWhiteSpace(aiAction) ? null : aiAction.Trim(), + CreatedAtUtc = DateTimeOffset.UtcNow, + }); + + job.CoverLetterText = next.Length == 0 ? null : next; + // HasCoverLetter is derived from attachments elsewhere; a written draft counts too. + if (next.Length > 0) job.HasCoverLetter = true; + + await _db.SaveChangesAsync(ct); + return await BuildCoverLetterAsync(ownerUserId, job, ct); + } + + // Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored + // from is still in the history. + public async Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct) + { + var job = await _db.JobApplications + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + var snapshot = await _db.CoverLetterVersions.AsNoTracking() + .FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct); + if (snapshot is null) return null; + + return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, ct); + } + + private async Task BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct) + { + var versions = await _db.CoverLetterVersions.AsNoTracking() + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id) + .OrderByDescending(v => v.Version) + .ToListAsync(ct); + + var current = versions.Count == 0 ? 0 : versions[0].Version; + + var aiCount = await _db.AiInteractions.AsNoTracking() + .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == job.Id && a.Module == "cover-letter", ct); + + return new CoverLetterDto( + job.CoverLetterText, + current, + versions.Select(v => new CoverLetterVersionDto( + v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(), + aiCount); + } + + private async Task NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var max = await _db.CoverLetterVersions.AsNoTracking() + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId) + .Select(v => (int?)v.Version) + .MaxAsync(ct); + return (max ?? 0) + 1; + } + + private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => + _db.JobApplications.AsNoTracking() + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index fd529b6..6321331 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -1044,6 +1044,25 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_Owner_Job_Sort" ON "ApplicationChecklistItems" ("OwnerUserId", "JobApplicationId", "SortOrder");"""); } + // Phase 5.4: append-only cover letter history. + static void EnsureCoverLetterVersionsTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CoverLetterVersions" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CoverLetterVersions" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "Version" INTEGER NOT NULL, + "Text" TEXT NOT NULL, + "Source" TEXT NOT NULL, + "AiAction" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_CoverLetterVersions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CoverLetterVersions_Owner_Job_Version" ON "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); @@ -1057,6 +1076,7 @@ public static class StartupInitializationExtensions EnsureCvBuilderTables(conn); EnsureAiInteractionsTable(conn); EnsureApplicationChecklistTable(conn); + EnsureCoverLetterVersionsTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -1651,6 +1671,7 @@ public static class StartupInitializationExtensions DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime"); + DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime"); if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications")) { @@ -1734,6 +1755,27 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "CoverLetterVersions") && HasMySqlTable(conn, "JobApplications")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CoverLetterVersions` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, + `Version` int NOT NULL, + `Text` longtext NOT NULL, + `Source` varchar(32) NOT NULL, + `AiAction` varchar(32) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CoverLetterVersions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE + );"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id"); + EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id"); diff --git a/Models/CoverLetterVersion.cs b/Models/CoverLetterVersion.cs new file mode 100644 index 0000000..bb13c08 --- /dev/null +++ b/Models/CoverLetterVersion.cs @@ -0,0 +1,41 @@ +namespace JobTrackerApi.Models; + +// Phase 5.4 — Application Assets. +// +// Append-only history for a job application's cover letter. JobApplication.CoverLetterText stays the +// CURRENT text and the existing API contract around it is unchanged; this only records what it used +// to be, so an AI rewrite or a bad edit is never destructive. Mirrors CvVariantVersion: restore +// re-saves an old snapshot as a new version rather than rewinding. +// +// Reconciler-owned (docs/infrastructure/database-ownership.md) — its migration is a no-op. +public sealed class CoverLetterVersion +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + + public int Version { get; set; } + public string Text { get; set; } = string.Empty; + + // manual | ai | template | restore — how this text came to exist, so the history list can show + // whether the user wrote it or approved it from a suggestion. + public string Source { get; set; } = "manual"; + + // For an AI-sourced version: which action produced it (generate | improve | shorten | expand | + // tone | tailor). Null for anything the user typed. + public string? AiAction { get; set; } + + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public static class CoverLetterSources +{ + public const string Manual = "manual"; + public const string Ai = "ai"; + public const string Template = "template"; + public const string Restore = "restore"; + + public static bool IsValid(string? value) => + value is Manual or Ai or Template or Restore; +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index a648a0f..b36bf52 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -21,7 +21,8 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus | Timeline | `JobEvent` — interpreted, never replaced | | Analysis / Match | the advert and the master `CareerProfile`, read deterministically | | Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals | -| CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile` | +| CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile`; the application only points at one | +| Cover Letter | `JobApplication.CoverLetterText` + `CoverLetterVersions` history | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | | Documents | `Attachment` | | Communication | `Correspondence` | @@ -222,6 +223,80 @@ Suggestions describe what the user could change. They never change it. With no profile it returns score 0 and asks the user to build one, rather than implying a bad match. +## Application assets (Phase 5.4) + +The workspace becomes the place an application is prepared. The rule is one-directional: + +``` +CareerProfile → CvVariant → application-specific output +``` + +Nothing flows back up. No code path in this phase writes to `CareerProfile` or its children — +`Tailoring_never_writes_to_the_career_profile_or_the_variant` pins it. + +### What is owned where + +| Asset | Owned by | This phase adds | +|---|---|---| +| CV content | `CareerProfile` (master) | nothing | +| CV variant, preview, PDF, themes, version history | `CvVariantService` / `/api/cv` | nothing | +| Which variant an application uses | `CvVariant.JobApplicationId` | the attach/detach route | +| Documents | `Attachment` / `/api/attachments` | nothing | +| Cover letter text | `JobApplication.CoverLetterText` | version history | +| AI narrative | `AiInteraction` | nothing | + +The only new table is `CoverLetterVersions` — reconciler-owned, no-op migration, guarded on +`JobApplications` (`docs/infrastructure/database-ownership.md`). Verified on MariaDB 11: +`int AUTO_INCREMENT` PK, `varchar(255)` owner, `datetime(6)`, composite index inside the key limit. + +### CV integration + +`GET/PUT /{id}/cv` reads and re-points. Attaching sets `CvVariant.JobApplicationId`; **one variant per +application**, so the workspace can answer "which CV am I sending". Replacing detaches the previous +variant rather than deleting it — it is still the user's to reuse. Everything else (create, duplicate, +edit, theme, preview, export PDF, versions, restore) is a link into the existing CV builder. There is +deliberately no second CV system. + +### Tailoring + +`GET /{id}/tailoring` composes the Phase 5.3 analysis and match into five suggestion kinds: skills to +highlight, experience to prioritise, projects to emphasise, keywords to include, gaps to address. + +Deterministic and advisory. It returns what the user *could* emphasise; the user edits the variant in +the builder. Nothing auto-applies, and no suggestion mutates a variant or the profile. + +### Cover letter + +`JobApplication.CoverLetterText` stays the current text and its existing API contract is unchanged. +`CoverLetterVersions` records what it used to be, so an AI rewrite is never destructive. + +- Every save that changes the text snapshots a new version; an unchanged save is a no-op, so autosave + never burns history. +- **Restore is additive** — the old text returns as a *new* version, so what you restored from is + still there. +- `Source` (`manual | ai | template | restore`) and `AiAction` record how each version came about, so + the history shows what the user wrote versus what they approved from a suggestion. +- An AI generation on its own is only an `AiInteraction`. It becomes a version when the user saves it + — that is what "requires approval" means here. + +Creation methods: write it, start from the built-in template, or generate from the AI panel below the +editor. The editor is always the user's; generation is never triggered by opening the page. + +### Documents + +Unchanged. The existing `Attachments` component and `/api/attachments` already handle CV, cover +letter, certificates, portfolio and other files with a `Purpose` field, and `JobApplication.HasResume` +/ `HasCoverLetter` / `HasPortfolio` are derived from it. The workspace mounts that component; no new +storage, no duplicate upload path. Files stay private to the owning user. + +### Future extension points + +- **Another asset type**: add a section, compose the service that already owns it — do not add storage. +- **AI cover-letter actions** (improve, shorten, expand, tone, tailor): already modelled by + `CoverLetterVersion.AiAction`; wire a new mode in `AiWorkspaceService` and save the approved result. +- **Multiple attached variants**: relax the one-per-application rule in `AttachVariantAsync`; the DTO + already carries the full variant list. + ## Extension points - **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven. @@ -236,5 +311,6 @@ With no profile it returns score 0 and asks the user to build one, rather than i custom items, reordering, dismissal; readiness refactored into a projection of it. 3. ✅ Application intelligence — timeline interpretation, structured job analysis, career matching (Phase 5.3, all three deterministic and read-only). -4. CV integration. +4. ✅ Application assets — CV variant association, tailoring suggestions, cover letter workflow with + version history, documents (Phase 5.4). 7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements. diff --git a/docs/infrastructure/database-ownership.md b/docs/infrastructure/database-ownership.md index 49d7658..c4a36e8 100644 --- a/docs/infrastructure/database-ownership.md +++ b/docs/infrastructure/database-ownership.md @@ -75,7 +75,8 @@ Created by `StartupInitializationExtensions`, with a **no-op migration** holding `CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`, `CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`), `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`, -`ApplicationChecklistItems`, `TwoFactorRecoveryCodes`, `TrustedDevices`, `UserSessions`. +`ApplicationChecklistItems`, `CoverLetterVersions`, `TwoFactorRecoveryCodes`, `TrustedDevices`, +`UserSessions`. No-op migrations, each with a comment explaining why: @@ -86,6 +87,7 @@ No-op migrations, each with a comment explaining why: | `20260718131138_AddAiInteractions` | `AiInteractions` | | `20260719085904_AddApplicationChecklistItems` | `ApplicationChecklistItems` | | `20260719094728_SyncCareerChildKeyLengths` | snapshot sync only | +| `20260719120954_AddCoverLetterVersions` | `CoverLetterVersions` | ### Dependency guards @@ -94,7 +96,7 @@ skips it on a fresh database and pass 2 creates it: | Table | Waits for | |---|---| -| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems` | `JobApplications` (migration-owned) | +| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions` | `JobApplications` (migration-owned) | | `CvVariantVersions` | `CvVariants` | | `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` | | `CvExtractionRuns` | `CvUploadArtifacts` | diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx new file mode 100644 index 0000000..c0d8322 --- /dev/null +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { + ApplicationCoverLetterSection, ApplicationCvSection, +} from "./components/ApplicationAssets"; +import { api } from "./api"; + +jest.mock("./api", () => ({ + api: { + get: jest.fn(), + put: jest.fn(), + post: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.", +})); + +const mockedApi = api as jest.Mocked; + +const cv = { + attachedVariantId: 3, + attachedVariantName: "Backend CV", + attachedThemeId: "nordic", + attachedVersion: 4, + attachedUpdatedAtUtc: "2026-07-19T10:00:00Z", + attachedIsPublic: false, + hasTailoredCvText: false, + availableVariants: [ + { id: 3, name: "Backend CV", themeId: "nordic", publicSlug: "abc", isPublic: false, version: 4, jobApplicationId: 7, updatedAtUtc: "2026-07-19T10:00:00Z" }, + { id: 5, name: "Generalist CV", themeId: "modern", publicSlug: "def", isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: "2026-07-18T10:00:00Z" }, + ], +}; + +const tailoring = { + hasJobDescription: true, + hasCareerProfile: true, + hasAttachedVariant: true, + matchScore: 72, + suggestions: [ + { kind: "highlight-skills", title: "Skills to highlight", detail: "The advert asks for these.", items: ["C#", "SQL"] }, + { kind: "gaps", title: "Gaps to address", detail: null, items: ["Kubernetes"] }, + ], + aiSuggestionCount: 0, +}; + +const coverLetter = { + text: "Dear team", + currentVersion: 2, + versions: [ + { version: 2, source: "ai", aiAction: "improve", length: 9, createdAtUtc: "2026-07-19T10:00:00Z", isCurrent: true }, + { version: 1, source: "manual", aiAction: null, length: 40, createdAtUtc: "2026-07-19T09:00:00Z", isCurrent: false }, + ], + aiSuggestionCount: 1, +}; + +function routeGet(overrides: Record = {}) { + mockedApi.get.mockImplementation((url: string) => { + if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any); + if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any); + return Promise.resolve({ data: overrides.cv ?? cv } as any); + }); +} + +beforeEach(() => jest.clearAllMocks()); + +// ---------- CV ---------- + +test("cv section shows the attached variant and the ones available to attach", async () => { + routeGet(); + + render(); + + expect(await screen.findByText("Backend CV")).toBeInTheDocument(); + expect(screen.getByText(/Theme nordic · version 4/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute( + "href", "/cv-builder?variant=3"); +}); + +test("attaching a different variant only re-points the application", async () => { + routeGet(); + mockedApi.put.mockResolvedValue({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any); + + render(); + fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i })); + fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ })); + + await waitFor(() => + expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 5 })); +}); + +test("cv section points at the builder when there are no variants", async () => { + routeGet({ cv: { ...cv, attachedVariantId: null, attachedVariantName: null, availableVariants: [] } }); + + render(); + + expect(await screen.findByText(/No CV variants yet/i)).toBeInTheDocument(); +}); + +test("tailoring renders suggestions grouped by kind", async () => { + routeGet(); + + render(); + + expect(await screen.findByText("Skills to highlight")).toBeInTheDocument(); + expect(screen.getByText("Gaps to address")).toBeInTheDocument(); + expect(screen.getByText("Kubernetes")).toBeInTheDocument(); +}); + +test("tailoring asks for a career profile when there is none", async () => { + routeGet({ tailoring: { ...tailoring, hasCareerProfile: false, suggestions: [] } }); + + render(); + + expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument(); +}); + +// ---------- Cover letter ---------- + +test("cover letter loads the current text and its history", async () => { + routeGet(); + + render(); + + expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument(); + expect(screen.getByText("v2")).toBeInTheDocument(); + expect(screen.getByText("ai · improve")).toBeInTheDocument(); + expect(screen.getByText("Current")).toBeInTheDocument(); +}); + +test("editing marks the draft dirty and saving sends the new text", async () => { + routeGet(); + mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any); + + render(); + fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } }); + + expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( + "/jobapplications/7/cover-letter", + { text: "Dear hiring team", source: "manual", aiAction: undefined }, + )); +}); + +test("discarding returns to the saved text", async () => { + routeGet(); + + render(); + fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "scratch" } }); + fireEvent.click(screen.getByRole("button", { name: /Discard changes/i })); + + expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument(); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test("restoring an old version calls the restore endpoint", async () => { + routeGet(); + mockedApi.post.mockResolvedValue({ data: coverLetter } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( + "/jobapplications/7/cover-letter/versions/1/restore")); +}); + +test("an empty cover letter offers the template and an empty history", async () => { + routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } }); + + render(); + + expect(await screen.findByText(/No versions yet/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Start from template/i })); + expect((screen.getByLabelText("Cover letter") as HTMLTextAreaElement).value) + .toContain("Dear Hiring Manager"); +}); + +test("a failed load surfaces an error", async () => { + mockedApi.get.mockRejectedValue(new Error("boom")); + + render(); + + expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index dbb2631..b8dfa49 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -79,8 +79,8 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile { key: "analysis", label: "Analysis" }, { key: "match", label: "Match" }, { key: "checklist", label: "Checklist" }, - { key: "cv", label: "CV", milestone: 6 }, - { key: "cover-letter", label: "Cover Letter", milestone: 7 }, + { key: "cv", label: "CV" }, + { key: "cover-letter", label: "Cover Letter" }, { key: "portfolio", label: "Portfolio", milestone: 8 }, { key: "documents", label: "Documents" }, { key: "interview", label: "Interview Prep" }, @@ -169,6 +169,71 @@ export const applicationIntelligenceApi = { match: (jobId: number) => api.get(`/jobapplications/${jobId}/match`).then((r) => r.data), }; +// Phase 5.4 — Application Assets. CV variant CRUD, preview, PDF export and version history stay on +// /api/cv (the existing CV builder). These types cover only what is application-scoped. +export type CvVariantSummary = { + id: number; + name: string; + themeId: string; + publicSlug: string; + isPublic: boolean; + version: number; + jobApplicationId: number | null; + updatedAtUtc: string; +}; + +export type ApplicationCv = { + attachedVariantId: number | null; + attachedVariantName: string | null; + attachedThemeId: string | null; + attachedVersion: number | null; + attachedUpdatedAtUtc: string | null; + attachedIsPublic: boolean; + hasTailoredCvText: boolean; + availableVariants: CvVariantSummary[]; +}; + +export type TailoringSuggestion = { kind: string; title: string; detail: string | null; items: string[] }; + +export type TailoringPlan = { + hasJobDescription: boolean; + hasCareerProfile: boolean; + hasAttachedVariant: boolean; + matchScore: number; + suggestions: TailoringSuggestion[]; + aiSuggestionCount: number; +}; + +export type CoverLetterVersion = { + version: number; + source: string; + aiAction: string | null; + length: number; + createdAtUtc: string; + isCurrent: boolean; +}; + +export type CoverLetter = { + text: string | null; + currentVersion: number; + versions: CoverLetterVersion[]; + aiSuggestionCount: number; +}; + +export const applicationAssetsApi = { + cv: (jobId: number) => api.get(`/jobapplications/${jobId}/cv`).then((r) => r.data), + attachVariant: (jobId: number, variantId: number | null) => + api.put(`/jobapplications/${jobId}/cv`, { variantId }).then((r) => r.data), + tailoring: (jobId: number) => + api.get(`/jobapplications/${jobId}/tailoring`).then((r) => r.data), + coverLetter: (jobId: number) => + api.get(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data), + saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) => + api.put(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data), + restoreCoverLetter: (jobId: number, version: number) => + api.post(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data), +}; + export const applicationChecklistApi = { get: (jobId: number) => api.get(`/jobapplications/${jobId}/checklist`).then((r) => r.data), diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx new file mode 100644 index 0000000..295fb80 --- /dev/null +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -0,0 +1,368 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { + Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField, + Tooltip, Typography, +} from "@mui/material"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import RestoreIcon from "@mui/icons-material/Restore"; + +import { getApiErrorMessage } from "../api"; +import { + ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi, +} from "../applicationWorkspace"; + +// Phase 5.4 — Application Assets sections for the workspace. +// +// These compose systems that already exist. CV editing, preview, PDF export, themes and version +// history all live in the CV builder at /cv-builder — this section only chooses WHICH variant the +// application uses and links out. The cover letter is the one thing genuinely owned here, because it +// is application-specific by nature. docs/architecture/application-workspace.md. + +function useAsset(load: () => Promise, deps: React.DependencyList) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const run = useCallback(load, deps); + + const reload = useCallback(() => { + let cancelled = false; + setLoading(true); + run() + .then((r) => { + if (!cancelled) { + setData(r); + setError(null); + } + }) + .catch((err) => { + if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section.")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [run]); + + useEffect(() => reload(), [reload]); + + return { data, error, loading, setData, setError, reload }; +} + +function Shell({ title, subtitle, loading, error, children }: { + title: string; + subtitle?: string; + loading: boolean; + error: string | null; + children: React.ReactNode; +}) { + return ( + + {title} + {subtitle && {subtitle}} + + {loading ? ( + {[0, 1, 2].map((i) => )} + ) : error ? ( + {error} + ) : ( + children + )} + + ); +} + +// ---------- CV ---------- + +export function ApplicationCvSection({ jobId }: { jobId: number }) { + const { data, error, loading, setData, setError } = useAsset( + () => applicationAssetsApi.cv(jobId), + [jobId], + ); + const [busy, setBusy] = useState(false); + + const attach = async (variantId: number | null) => { + setBusy(true); + try { + setData(await applicationAssetsApi.attachVariant(jobId, variantId)); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not change the attached CV.")); + } finally { + setBusy(false); + } + }; + + const attached = data?.attachedVariantId ?? ""; + + return ( + + + + {(data?.availableVariants.length ?? 0) === 0 ? ( + + No CV variants yet. Build one in the CV builder — it starts from your master career + profile, so you never retype your history. + + ) : ( + attach(e.target.value === "" ? null : Number(e.target.value))} + helperText="Changing this only re-points the application. The variant itself is untouched." + > + None + {(data?.availableVariants ?? []).map((v) => ( + + {v.name} · {v.themeId} · v{v.version} + + ))} + + )} + + {data?.attachedVariantId ? ( + + + + {data.attachedVariantName} + + Theme {data.attachedThemeId} · version {data.attachedVersion} + {data.attachedIsPublic ? " · public" : ""} + + + + + + + + ) : ( + + No CV attached to this application yet. + + )} + + {data?.hasTailoredCvText && ( + + This application also has legacy tailored CV text saved on it. A CV variant supersedes it. + + )} + + + + + + ); +} + +// ---------- Tailoring ---------- + +export function ApplicationTailoringSection({ jobId }: { jobId: number }) { + const { data, error, loading } = useAsset( + () => applicationAssetsApi.tailoring(jobId), + [jobId], + ); + + return ( + + + {data && !data.hasCareerProfile && ( + + Build your career profile to get experience and project suggestions. + + )} + {data && !data.hasJobDescription && ( + + Paste the advert text to get keyword and requirement suggestions. + + )} + + {(data?.suggestions.length ?? 0) === 0 ? ( + + Nothing to suggest yet. + + ) : ( + (data?.suggestions ?? []).map((s) => ( + + {s.title} + {s.detail && ( + {s.detail} + )} + + {s.items.map((item) => ( + + ))} + + + )) + )} + + + ); +} + +// ---------- Cover letter ---------- + +const TEMPLATE = `Dear Hiring Manager, + +I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.] + +In my current role I [the most relevant thing you have done, with a concrete outcome]. [A second example that matches what the advert asks for.] + +[Why you want this job, in your own words.] + +I would welcome the chance to talk it through. + +Kind regards, +[Your name]`; + +export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) { + const { data, error, loading, setData, setError } = useAsset( + () => applicationAssetsApi.coverLetter(jobId), + [jobId], + ); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + + // The textarea is only seeded from the server until the user starts typing, so a reload never + // clobbers unsaved edits. + const text = draft ?? data?.text ?? ""; + const dirty = draft !== null && draft !== (data?.text ?? ""); + + const save = async (value: string, source = "manual") => { + setBusy(true); + try { + setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source)); + setDraft(null); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not save the cover letter.")); + } finally { + setBusy(false); + } + }; + + const restore = async (version: number) => { + setBusy(true); + try { + setData(await applicationAssetsApi.restoreCoverLetter(jobId, version)); + setDraft(null); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not restore that version.")); + } finally { + setBusy(false); + } + }; + + return ( + + + + setDraft(e.target.value)} + placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below." + /> + + + + + + {dirty && ( + + )} + + + + + + {(data?.versions.length ?? 0) === 0 ? ( + + No versions yet. The first save starts the history. + + ) : ( + + {(data?.versions ?? []).map((v) => ( + + + + v{v.version} + + {v.isCurrent && } + + + {new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters + + + {!v.isCurrent && ( + + + restore(v.version)} + > + + + + + )} + + ))} + + )} + + + ); +} diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 4912623..04ba6b3 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -22,6 +22,9 @@ import ApplicationChecklist from "../components/ApplicationChecklist"; import { ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, } from "../components/ApplicationIntelligence"; +import { + ApplicationCoverLetterSection, ApplicationCvSection, +} from "../components/ApplicationAssets"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, } from "../applicationWorkspace"; @@ -113,7 +116,15 @@ export default function ApplicationWorkspacePage() { {section === "checklist" && jobId > 0 && ( )} - {["cv", "cover-letter", "portfolio", "notes"].includes(section) && ( + {section === "cv" && jobId > 0 && } + {section === "cover-letter" && jobId > 0 && ( + <> + + {/* Generation stays an explicit user action, below the editor the user owns. */} + + + )} + {["portfolio", "notes"].includes(section) && ( )}