From 02b38f7acbb4c4a770511cbdd4186297ebeabbc9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 19 Jul 2026 16:36:27 +0200 Subject: [PATCH] feat(workspace): application assets workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5.4. Connects the career outputs a user already has to one job application, without building a second copy of any of them. The flow is strictly one-directional — CareerProfile -> CvVariant -> application output — and nothing writes back up. No code path in this phase touches CareerProfile or its children. CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one variant to an application via CvVariant.JobApplicationId; replacing detaches the previous variant instead of deleting it. Creating, duplicating, editing, theming, previewing, exporting PDF and version history all stay in the existing CV builder, which the section links into. There is no second CV system. Tailoring composes the Phase 5.3 analysis and match into skills to highlight, experience to prioritise, projects to emphasise, keywords to include and gaps to address. Deterministic and advisory: it says what the user could emphasise and the user edits the variant themselves. Nothing auto-applies. Cover letters gain the history they were missing. JobApplication.CoverLetterText stays the current text with its API contract unchanged; CoverLetterVersions records what it used to be, so an AI rewrite is never destructive. Restore is additive — the old text comes back as a new version, so what you restored from still exists. Source and AiAction record whether the user wrote a version or approved it from a suggestion, and an AI generation only becomes a version once the user saves it. Documents are untouched: the existing Attachment system already covers CV, cover letter, certificates and portfolio files with a Purpose field, so the workspace mounts that component rather than adding a second upload path. CoverLetterVersions is the only new table — reconciler-owned, no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index inside the key limit. 360 backend tests, 115 frontend tests, type check, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 --- Data/JobTrackerContext.cs | 18 + JobTrackerApi.Tests/ApplicationAssetsTests.cs | 308 +++ .../ApplicationAssetsController.cs | 89 + ...9120954_AddCoverLetterVersions.Designer.cs | 2272 +++++++++++++++++ .../20260719120954_AddCoverLetterVersions.cs | 28 + .../JobTrackerContextModelSnapshot.cs | 53 + JobTrackerApi/Program.cs | 1 + .../Services/ApplicationAssetsService.cs | 299 +++ .../StartupInitializationExtensions.cs | 42 + Models/CoverLetterVersion.cs | 41 + docs/architecture/application-workspace.md | 80 +- docs/infrastructure/database-ownership.md | 6 +- .../src/application-assets.test.tsx | 187 ++ job-tracker-ui/src/applicationWorkspace.ts | 69 +- .../src/components/ApplicationAssets.tsx | 368 +++ .../src/views/ApplicationWorkspacePage.tsx | 13 +- 16 files changed, 3867 insertions(+), 7 deletions(-) create mode 100644 JobTrackerApi.Tests/ApplicationAssetsTests.cs create mode 100644 JobTrackerApi/Controllers/ApplicationAssetsController.cs create mode 100644 JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.Designer.cs create mode 100644 JobTrackerApi/Migrations/20260719120954_AddCoverLetterVersions.cs create mode 100644 JobTrackerApi/Services/ApplicationAssetsService.cs create mode 100644 Models/CoverLetterVersion.cs create mode 100644 job-tracker-ui/src/application-assets.test.tsx create mode 100644 job-tracker-ui/src/components/ApplicationAssets.tsx 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) && ( )}