diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 450664a..7883200 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -47,6 +47,7 @@ namespace JobTrackerApi.Data public DbSet AiInteractions => Set(); public DbSet ApplicationChecklistItems => Set(); public DbSet CoverLetterVersions => Set(); + public DbSet InterviewPrepItems => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -384,6 +385,23 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // Phase 5.5: user-owned interview preparation. Unlike InterviewPrepNote (an AI cache), nothing + // regenerates this. 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.Category).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.Source).HasMaxLength(16); + modelBuilder.Entity().Property(x => x.Title).HasMaxLength(500); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.SortOrder }); + 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/InterviewPrepTests.cs b/JobTrackerApi.Tests/InterviewPrepTests.cs new file mode 100644 index 0000000..570c983 --- /dev/null +++ b/JobTrackerApi.Tests/InterviewPrepTests.cs @@ -0,0 +1,252 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Phase 5.5 — Interview preparation and follow-up. The properties under test: prep content belongs to +// the user and nothing regenerates it, follow-ups reuse the existing FollowUpAt + checklist rather +// than a second reminder system, and the timeline reads the new lifecycle events. +public sealed class InterviewPrepTests +{ + private static (JobTrackerContext db, InterviewPrepService prep, ApplicationTimelineService timeline) 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); + return (db, new InterviewPrepService(db), new ApplicationTimelineService(db)); + } + + private static async Task SeedJobAsync(JobTrackerContext db, string owner, Action? tweak = null) + { + 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 = "Interview", + }; + tweak?.Invoke(job); + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + // ---------- Part 1: interview preparation ---------- + + [Fact] + public async Task Prep_items_group_by_category_in_preparation_order() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Question, "What does success look like?", null, null, null), default); + await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.CompanyResearch, "Funding history", "Series B in 2025.", null, null), default); + await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Technical, "System design", null, null, null), default); + + var result = await prep.GetAsync("user-1", job.Id, default); + + Assert.Equal( + new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question }, + result!.Groups.Select(g => g.Category)); + Assert.Equal("Company research", result.Groups[0].Label); + } + + [Fact] + public async Task Marking_items_prepared_drives_the_progress_percentage() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + var a = await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "One", null, null, null), default); + await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "Two", null, null, null), default); + + await prep.UpdateAsync("user-1", job.Id, a!.Id, new InterviewPrepInput(null, null, null, null, true), default); + var result = await prep.GetAsync("user-1", job.Id, default); + + Assert.Equal(2, result!.Total); + Assert.Equal(1, result.Prepared); + Assert.Equal(50, result.Percent); + } + + [Fact] + public async Task An_accepted_ai_suggestion_is_recorded_as_ai_but_stays_editable() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var item = await prep.AddAsync("user-1", job.Id, + new InterviewPrepInput(InterviewPrepCategories.Behavioural, "Tell me about a conflict", "Draft answer", InterviewPrepSources.Ai, null), default); + + Assert.Equal(InterviewPrepSources.Ai, item!.Source); + + var edited = await prep.UpdateAsync("user-1", job.Id, item.Id, + new InterviewPrepInput(null, null, "My own answer", null, null), default); + + Assert.Equal("My own answer", edited!.Content); + } + + [Fact] + public async Task Generating_ai_history_does_not_create_prep_items() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.AiInteractions.Add(new AiInteraction + { + OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "interview", + Title = "Interview prep", Provider = "p", ResultJson = """{"text":"Likely questions..."}""", + CreatedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var result = await prep.GetAsync("user-1", job.Id, default); + + // A suggestion is history until the user accepts it. Nothing appears in their prep by itself. + Assert.Equal(0, result!.Total); + Assert.Equal(1, result.AiSuggestionCount); + } + + [Fact] + public async Task Prep_items_are_deletable_and_scoped_to_their_owner() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var mine = await SeedJobAsync(db, "user-1"); + var theirs = await SeedJobAsync(db, "user-2"); + var item = await prep.AddAsync("user-1", mine.Id, new InterviewPrepInput(null, "Mine", null, null, null), default); + + Assert.Null(await prep.GetAsync("user-1", theirs.Id, default)); + Assert.Null(await prep.AddAsync("user-1", theirs.Id, new InterviewPrepInput(null, "Sneak", null, null, null), default)); + Assert.False(await prep.DeleteAsync("user-1", theirs.Id, item!.Id, default)); + + Assert.True(await prep.DeleteAsync("user-1", mine.Id, item.Id, default)); + Assert.Equal(0, (await prep.GetAsync("user-1", mine.Id, default))!.Total); + } + + [Fact] + public async Task Prep_never_writes_to_the_career_profile() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.CareerProfiles.Add(new CareerProfile + { + OwnerUserId = "user-1", + Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", BulletsJson = """["Original"]""" } }, + }); + await db.SaveChangesAsync(); + var before = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); + + await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Star, "A time I led", "Content", InterviewPrepSources.Ai, null), default); + + var after = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); + Assert.Equal(before.Version, after.Version); + Assert.Equal(before.Experiences[0].BulletsJson, after.Experiences[0].BulletsJson); + } + + // ---------- Part 4: follow-up ---------- + + [Fact] + public async Task Setting_a_follow_up_updates_the_existing_field_and_records_an_event() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + var due = DateTime.Today.AddDays(7); + + var result = await prep.SetFollowUpAsync("user-1", job.Id, due, "Chase the recruiter", default); + + Assert.Equal(due, result!.FollowUpAt); + Assert.Equal("Chase the recruiter", result.NextAction); + // The same field the reminder service and RulesEngine already read — not a parallel store. + Assert.Equal(due, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).FollowUpAt); + Assert.Equal("FollowUpSet", (await db.JobEvents.AsNoTracking().SingleAsync()).Type); + } + + [Fact] + public async Task Follow_up_counts_open_checklist_tasks_rather_than_owning_them() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.ApplicationChecklistItems.Add(new ApplicationChecklistItem + { + OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Chase", + Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Pending, + }); + db.ApplicationChecklistItems.Add(new ApplicationChecklistItem + { + OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Done one", + Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Done, + }); + await db.SaveChangesAsync(); + + var result = await prep.GetFollowUpAsync("user-1", job.Id, default); + + Assert.Equal(1, result!.OpenFollowUpTasks); + // No follow-up table was created — the tasks live in the checklist. + Assert.Equal(2, await db.ApplicationChecklistItems.CountAsync()); + } + + [Fact] + public async Task Clearing_a_follow_up_is_recorded_too() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => j.FollowUpAt = DateTime.Today.AddDays(3)); + + var result = await prep.SetFollowUpAsync("user-1", job.Id, null, null, default); + + Assert.Null(result!.FollowUpAt); + Assert.Equal(1, await db.JobEvents.CountAsync()); + } + + [Fact] + public async Task Follow_up_is_not_reachable_for_another_users_application() + { + var (db, prep, _) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await prep.GetFollowUpAsync("user-1", other.Id, default)); + Assert.Null(await prep.SetFollowUpAsync("user-1", other.Id, DateTime.Today, null, default)); + } + + // ---------- Part 5: timeline integration ---------- + + [Fact] + public async Task Timeline_reads_the_new_lifecycle_events() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewScheduled", NewValue = "2026-08-01", At = DateTime.Now.AddDays(-2) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewCompleted", At = DateTime.Now.AddDays(-1) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "OfferReceived", At = DateTime.Now }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "FollowUpCompleted", At = DateTime.Now }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList(); + Assert.Contains("Interview scheduled for 1 August 2026", summaries); + Assert.Contains("Interview completed", summaries); + Assert.Contains("Offer received", summaries); + Assert.Contains("Follow-up completed", summaries); + + // Interviews and offers are milestones; a completed follow-up is routine. + Assert.Equal(3, result.Milestones.Count); + Assert.DoesNotContain(result.Milestones, m => m.Type == "FollowUpCompleted"); + } +} diff --git a/JobTrackerApi/Controllers/InterviewPrepController.cs b/JobTrackerApi/Controllers/InterviewPrepController.cs new file mode 100644 index 0000000..9833a04 --- /dev/null +++ b/JobTrackerApi/Controllers/InterviewPrepController.cs @@ -0,0 +1,86 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Phase 5.5 — Interview preparation and follow-up. +// +// Prep content is the user's; AI suggestions come from the existing /api/jobapplications/{id}/ai +// routes and only reach here once the user accepts one (POST with source "ai"). Communication stays +// entirely on the existing Correspondence routes — there is no messaging endpoint here. +// docs/architecture/application-workspace.md. +[ApiController] +[Route("api/jobapplications/{jobId:int}")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class InterviewPrepController : ControllerBase +{ + public sealed record SetFollowUpRequest(DateTime? FollowUpAt, string? NextAction); + + private readonly UserManager _users; + private readonly IInterviewPrepService _prep; + + public InterviewPrepController(UserManager users, IInterviewPrepService prep) + { + _users = users; + _prep = prep; + } + + [HttpGet("interview-prep")] + public async Task> Get(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _prep.GetAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("interview-prep")] + public async Task> Add(int jobId, [FromBody] InterviewPrepInput input, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + if (string.IsNullOrWhiteSpace(input?.Title)) return BadRequest("Title is required."); + var created = await _prep.AddAsync(userId, jobId, input, ct); + return created is null ? NotFound() : Ok(created); + } + + [HttpPatch("interview-prep/{itemId:int}")] + public async Task> Update(int jobId, int itemId, [FromBody] InterviewPrepInput input, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var updated = await _prep.UpdateAsync(userId, jobId, itemId, input, ct); + return updated is null ? NotFound() : Ok(updated); + } + + [HttpDelete("interview-prep/{itemId:int}")] + public async Task Delete(int jobId, int itemId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + return await _prep.DeleteAsync(userId, jobId, itemId, ct) ? NoContent() : NotFound(); + } + + [HttpGet("follow-up")] + public async Task> GetFollowUp(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _prep.GetFollowUpAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("follow-up")] + public async Task> SetFollowUp(int jobId, [FromBody] SetFollowUpRequest request, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _prep.SetFollowUpAsync(userId, jobId, request?.FollowUpAt, request?.NextAction, ct); + return result is null ? NotFound() : Ok(result); + } + + private async Task CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; +} diff --git a/JobTrackerApi/Migrations/20260719145044_AddInterviewPrepItems.Designer.cs b/JobTrackerApi/Migrations/20260719145044_AddInterviewPrepItems.Designer.cs new file mode 100644 index 0000000..95d38de --- /dev/null +++ b/JobTrackerApi/Migrations/20260719145044_AddInterviewPrepItems.Designer.cs @@ -0,0 +1,2336 @@ +// +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("20260719145044_AddInterviewPrepItems")] + partial class AddInterviewPrepItems + { + /// + 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.InterviewPrepItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPrepared") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("InterviewPrepItems"); + }); + + 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.InterviewPrepItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + 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/20260719145044_AddInterviewPrepItems.cs b/JobTrackerApi/Migrations/20260719145044_AddInterviewPrepItems.cs new file mode 100644 index 0000000..8a6bfa8 --- /dev/null +++ b/JobTrackerApi/Migrations/20260719145044_AddInterviewPrepItems.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddInterviewPrepItems : Migration + { + // Deliberately a no-op. Scaffolded against SQLite, so on MariaDB it would emit TEXT datetimes + // and a PRIMARY KEY without AUTO_INCREMENT, and the composite index over those columns would + // exceed MySQL's 3072-byte key limit. + // + // InterviewPrepItems is reconciler-owned and provisioned by StartupInitializationExtensions, + // which carries correct DDL per provider and guards the create on JobApplications existing. + // 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 43aeb92..3fe00c5 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -1204,6 +1204,59 @@ namespace JobTrackerApi.Migrations b.ToTable("ImapConnections"); }); + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPrepared") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("InterviewPrepItems"); + }); + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => { b.Property("Id") @@ -2115,6 +2168,17 @@ namespace JobTrackerApi.Migrations b.Navigation("CvVariant"); }); + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => { b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index ba8865c..74d4c6b 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -46,6 +46,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/ApplicationTimelineService.cs b/JobTrackerApi/Services/ApplicationTimelineService.cs index cb3a01b..fb9ec6a 100644 --- a/JobTrackerApi/Services/ApplicationTimelineService.cs +++ b/JobTrackerApi/Services/ApplicationTimelineService.cs @@ -94,6 +94,13 @@ public sealed class ApplicationTimelineService : IApplicationTimelineService "Restored" => (CategoryLifecycle, "Application restored from trash", false), "Undo" => (CategoryLifecycle, "Change undone", false), "StatusChanged" => (CategoryStage, StatusSummary(e), IsMilestoneStatus(e.NewValue)), + // Phase 5.5 lifecycle events. Interviews and offers are milestones; scheduling and + // completing a follow-up is routine, so it stays out of the milestone spine. + "InterviewScheduled" => (CategoryStage, InterviewSummary(e, "Interview scheduled"), true), + "InterviewCompleted" => (CategoryStage, InterviewSummary(e, "Interview completed"), true), + "OfferReceived" => (CategoryStage, "Offer received", true), + "FollowUpCreated" => (CategoryFollowUp, FollowUpSummary(e), false), + "FollowUpCompleted" => (CategoryFollowUp, "Follow-up completed", false), "FollowUpSet" => (CategoryFollowUp, FollowUpSummary(e), false), "ResponseUpdated" => (CategoryCommunication, ResponseSummary(e), false), "ReplyReceived" => (CategoryCommunication, "Reply received", true), @@ -115,6 +122,15 @@ public sealed class ApplicationTimelineService : IApplicationTimelineService return from is null ? $"Moved to {to}" : $"Moved from {from} to {to}"; } + private static string InterviewSummary(JobEvent e, string prefix) + { + var detail = Clean(e.NewValue); + if (detail is null) return prefix; + return DateTime.TryParse(detail, out var parsed) + ? $"{prefix} for {parsed:d MMMM yyyy}" + : $"{prefix} — {detail}"; + } + private static string FollowUpSummary(JobEvent e) { var to = Clean(e.NewValue); diff --git a/JobTrackerApi/Services/InterviewPrepService.cs b/JobTrackerApi/Services/InterviewPrepService.cs new file mode 100644 index 0000000..3b545e8 --- /dev/null +++ b/JobTrackerApi/Services/InterviewPrepService.cs @@ -0,0 +1,221 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// Phase 5.5 — Interview preparation and follow-up. +// +// The prep content is the USER's. AI suggestions arrive through the existing AiWorkspaceService +// "interview" module and only become prep items when the user accepts them, which is what approval +// means here. Nothing in this service writes to the CareerProfile, a CvVariant, or the application's +// own fields — except FollowUpAt, which is the one thing a follow-up genuinely is. +// +// Follow-ups reuse what already exists: JobApplication.FollowUpAt for the date (RulesEngine and the +// reminder hosted service already act on it) and ApplicationChecklistItem for the task. No second +// reminder system. docs/architecture/application-workspace.md. +public sealed record InterviewPrepItemDto( + int Id, + string Category, + string Title, + string? Content, + string Source, + bool IsPrepared, + int SortOrder, + DateTimeOffset UpdatedAtUtc); + +public sealed record InterviewPrepGroupDto(string Category, string Label, IReadOnlyList Items); + +public sealed record InterviewPrepBoardDto( + IReadOnlyList Groups, + int Total, + int Prepared, + int Percent, + bool IsInterviewStage, + int AiSuggestionCount); + +public sealed record InterviewPrepInput(string? Category, string? Title, string? Content, string? Source, bool? IsPrepared); + +public sealed record FollowUpDto(DateTime? FollowUpAt, string? NextAction, bool ResponseReceived, int OpenFollowUpTasks); + +public interface IInterviewPrepService +{ + Task GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct); + Task UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct); + Task DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct); + + Task GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct); +} + +public sealed class InterviewPrepService : IInterviewPrepService +{ + private static readonly Dictionary Labels = new(StringComparer.Ordinal) + { + [InterviewPrepCategories.CompanyResearch] = "Company research", + [InterviewPrepCategories.Technical] = "Technical preparation", + [InterviewPrepCategories.Behavioural] = "Behavioural questions", + [InterviewPrepCategories.Star] = "STAR examples", + [InterviewPrepCategories.Question] = "Questions to ask them", + [InterviewPrepCategories.Note] = "Notes", + }; + + private readonly JobTrackerContext _db; + + public InterviewPrepService(JobTrackerContext db) + { + _db = db; + } + + public async Task GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + + var items = await _db.InterviewPrepItems.AsNoTracking() + .Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId) + .ToListAsync(ct); + + var aiCount = await _db.AiInteractions.AsNoTracking() + .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "interview", ct); + + var groups = items + .GroupBy(i => i.Category) + .OrderBy(g => InterviewPrepCategories.Rank(g.Key)) + .Select(g => new InterviewPrepGroupDto( + g.Key, + Labels.TryGetValue(g.Key, out var label) ? label : g.Key, + g.OrderBy(i => i.SortOrder).ThenBy(i => i.Id).Select(Project).ToList())) + .ToList(); + + var prepared = items.Count(i => i.IsPrepared); + + return new InterviewPrepBoardDto( + groups, + items.Count, + prepared, + items.Count == 0 ? 0 : (int)Math.Round(prepared * 100.0 / items.Count), + IsInterviewStage(job.Status), + aiCount); + } + + public async Task AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct) + { + var title = (input.Title ?? string.Empty).Trim(); + if (title.Length == 0) return null; + + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + + var maxSort = await _db.InterviewPrepItems + .Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId) + .Select(i => (int?)i.SortOrder) + .MaxAsync(ct) ?? 0; + + var item = new InterviewPrepItem + { + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Category = InterviewPrepCategories.IsValid(input.Category) ? input.Category! : InterviewPrepCategories.Note, + Title = title, + Content = Blank(input.Content), + // An accepted AI suggestion is recorded as such, but is fully the user's to edit after. + Source = InterviewPrepSources.IsValid(input.Source) ? input.Source! : InterviewPrepSources.User, + IsPrepared = input.IsPrepared ?? false, + SortOrder = maxSort + 1, + }; + + _db.InterviewPrepItems.Add(item); + await _db.SaveChangesAsync(ct); + return Project(item); + } + + public async Task UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct) + { + var item = await _db.InterviewPrepItems + .FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct); + if (item is null) return null; + + if (!string.IsNullOrWhiteSpace(input.Title)) item.Title = input.Title!.Trim(); + if (input.Content is not null) item.Content = Blank(input.Content); + if (InterviewPrepCategories.IsValid(input.Category)) item.Category = input.Category!; + if (input.IsPrepared is not null) item.IsPrepared = input.IsPrepared.Value; + item.UpdatedAtUtc = DateTimeOffset.UtcNow; + + await _db.SaveChangesAsync(ct); + return Project(item); + } + + public async Task DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct) + { + var item = await _db.InterviewPrepItems + .FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct); + if (item is null) return false; + + _db.InterviewPrepItems.Remove(item); + await _db.SaveChangesAsync(ct); + return true; + } + + // ---------- follow-up ---------- + + public async Task GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + return await BuildFollowUpAsync(ownerUserId, job, ct); + } + + // Sets the date the existing reminder machinery already reads, and records a JobEvent so the + // timeline shows it. The follow-up TASK itself stays a checklist item — this does not invent a + // second to-do list. + public async Task SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct) + { + var job = await _db.JobApplications + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + var previous = job.FollowUpAt; + job.FollowUpAt = followUpAt; + if (nextAction is not null) job.NextAction = Blank(nextAction); + + // Same event type the rest of the app already emits, so the timeline reads it unchanged. + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = jobApplicationId, + Type = "FollowUpSet", + OldValue = previous?.ToString("yyyy-MM-dd"), + NewValue = followUpAt?.ToString("yyyy-MM-dd"), + At = DateTime.Now, + }); + + await _db.SaveChangesAsync(ct); + return await BuildFollowUpAsync(ownerUserId, job, ct); + } + + private async Task BuildFollowUpAsync(string ownerUserId, JobApplication job, CancellationToken ct) + { + // The follow-up tasks are checklist items — counted here, owned there. + var openTasks = await _db.ApplicationChecklistItems.AsNoTracking() + .CountAsync(i => i.OwnerUserId == ownerUserId + && i.JobApplicationId == job.Id + && i.Category == ChecklistCategories.FollowUp + && i.Status == ChecklistStatuses.Pending, ct); + + return new FollowUpDto(job.FollowUpAt, job.NextAction, job.ResponseReceived, openTasks); + } + + private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => + _db.JobApplications.AsNoTracking() + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + + private static bool IsInterviewStage(string? status) => + (status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase); + + private static string? Blank(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static InterviewPrepItemDto Project(InterviewPrepItem i) => + new(i.Id, i.Category, i.Title, i.Content, i.Source, i.IsPrepared, i.SortOrder, i.UpdatedAtUtc); +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 6321331..cfde895 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -1063,6 +1063,28 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CoverLetterVersions_Owner_Job_Version" ON "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version");"""); } + // Phase 5.5: user-owned interview preparation (not the AI cache InterviewPrepNote). + static void EnsureInterviewPrepItemsTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "InterviewPrepItems" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepItems" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "Category" TEXT NOT NULL, + "Title" TEXT NOT NULL, + "Content" TEXT NULL, + "Source" TEXT NOT NULL, + "IsPrepared" INTEGER NOT NULL, + "SortOrder" INTEGER NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "UpdatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_InterviewPrepItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_InterviewPrepItems_Owner_Job_Sort" ON "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "SortOrder");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); @@ -1077,6 +1099,7 @@ public static class StartupInitializationExtensions EnsureAiInteractionsTable(conn); EnsureApplicationChecklistTable(conn); EnsureCoverLetterVersionsTable(conn); + EnsureInterviewPrepItemsTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -1672,6 +1695,7 @@ public static class StartupInitializationExtensions DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime"); + DropMalformedMySqlTable(conn, "InterviewPrepItems", "CreatedAtUtc", "datetime"); if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications")) { @@ -1773,6 +1797,30 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "InterviewPrepItems") && HasMySqlTable(conn, "JobApplications")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepItems` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, + `Category` varchar(32) NOT NULL, + `Title` varchar(500) NOT NULL, + `Content` longtext NULL, + `Source` varchar(16) NOT NULL, + `IsPrepared` tinyint(1) NOT NULL, + `SortOrder` int NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `UpdatedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_InterviewPrepItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE + );"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepItems", "Id"); + EnsureMySqlIndex(conn, "InterviewPrepItems", "IX_InterviewPrepItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id"); EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`"); diff --git a/Models/InterviewPrepItem.cs b/Models/InterviewPrepItem.cs new file mode 100644 index 0000000..fa6ec24 --- /dev/null +++ b/Models/InterviewPrepItem.cs @@ -0,0 +1,72 @@ +namespace JobTrackerApi.Models; + +// Phase 5.5 — Interview preparation the USER owns. +// +// Distinct from InterviewPrepNote and AiWorkspaceNote, which are AI caches: both are regenerated when +// their context changes, so anything a user typed there would eventually be overwritten. This is the +// durable side — company research, technical notes, behavioural answers, STAR examples and the user's +// own questions — and nothing regenerates it. +// +// One table for every category rather than a table per category: they differ only by label, and a new +// category must not need a migration. Reconciler-owned +// (docs/infrastructure/database-ownership.md); its migration is a no-op. +public sealed class InterviewPrepItem +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + + // company-research | technical | behavioural | star | question | note + public string Category { get; set; } = InterviewPrepCategories.Note; + + // The prompt side: a question to answer, or the heading of a research note. + public string Title { get; set; } = string.Empty; + + // The user's own words. Always theirs to edit — an AI suggestion only lands here once saved. + public string? Content { get; set; } + + // user | ai — whether the user wrote this or accepted it from a suggestion. Recorded for honesty + // in the UI, not to restrict editing: an accepted suggestion is fully editable afterwards. + public string Source { get; set; } = InterviewPrepSources.User; + + // Practice tracking, so the section doubles as the preparation checklist. + public bool IsPrepared { get; set; } + + public int SortOrder { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public static class InterviewPrepCategories +{ + public const string CompanyResearch = "company-research"; + public const string Technical = "technical"; + public const string Behavioural = "behavioural"; + public const string Star = "star"; + public const string Question = "question"; + public const string Note = "note"; + + // Display order in the workspace: understand the company, then the role, then yourself, then what + // you want to ask them. + public static readonly string[] Order = + { + CompanyResearch, Technical, Behavioural, Star, Question, Note, + }; + + public static int Rank(string? category) + { + var i = Array.IndexOf(Order, category ?? Note); + return i < 0 ? Order.Length : i; + } + + public static bool IsValid(string? value) => Array.IndexOf(Order, value ?? string.Empty) >= 0; +} + +public static class InterviewPrepSources +{ + public const string User = "user"; + public const string Ai = "ai"; + + public static bool IsValid(string? value) => value is User or Ai; +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index b36bf52..96ca0c3 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -297,6 +297,66 @@ storage, no duplicate upload path. Files stay private to the owning user. - **Multiple attached variants**: relax the one-per-application rule in `AttachVariantAsync`; the DTO already carries the full variant list. +## Interview and follow-up (Phase 5.5) + +Completes the lifecycle after submission: prepare, communicate, chase. + +### Interview preparation + +There were already two per-application AI stores — `InterviewPrepNote` and `AiWorkspaceNote` — and +**both are caches**: each regenerates when its context signature changes, so anything a user typed +into them would eventually be overwritten. `InterviewPrepItem` is the durable, user-owned side. +Nothing regenerates it. + +One table covers every category (`company-research`, `technical`, `behavioural`, `star`, `question`, +`note`) — they differ only by label, and adding a category must not need a migration. Each item +carries the user's own `Content`, a `Source` (`user | ai`) recording whether they wrote it or accepted +a suggestion, and `IsPrepared`, which makes the section double as the preparation checklist. + +An accepted AI suggestion is marked `ai` for honesty, not to restrict editing — it is fully the +user's afterwards. + +### The AI boundary, restated + +Generation stays in `AiWorkspaceService`'s existing `interview` module, reached from +`AiWorkspacePanel`, authenticated and ownership-scoped like every other module, with each run appended +to `AiInteraction`. **A suggestion is history until the user adds it as a prep item.** Opening the +section generates nothing; `Generating_ai_history_does_not_create_prep_items` pins that. + +### Communication + +Unchanged. `Correspondence` already owns recruiter contacts, message history and notes, and the +workspace already mounts that component. No second messaging or history system was added. + +### Follow-up + +Reuses what exists rather than adding a tracker: + +- **The date** is `JobApplication.FollowUpAt` — the same field `RulesEngine` and + `FollowUpReminderHostedService` already act on. Writing it here means reminders keep working with no + new wiring. +- **The task** is an `ApplicationChecklistItem` in the `follow-up` category. The follow-up section + *counts* open tasks; it does not own them. +- **The record** is a `FollowUpSet` `JobEvent` — the same type the rest of the app emits, so the + timeline reads it unchanged. + +### Timeline integration + +`JobEvent` remains the source of history. The interpreter learned five more types: +`InterviewScheduled`, `InterviewCompleted`, `OfferReceived` (milestones) and `FollowUpCreated`, +`FollowUpCompleted` (routine, deliberately kept out of the milestone spine so it stays the "what +actually happened" summary). + +### Ownership + +`InterviewPrepItems` is reconciler-owned with a no-op migration, guarded on `JobApplications` +(`docs/infrastructure/database-ownership.md`). Verified on a fresh MariaDB 11: `int AUTO_INCREMENT` +PK, `varchar(255)` owner, `varchar(500)` title, `tinyint(1)` flag, `datetime(6)`, composite index +inside the key limit. + +`InterviewPrepBoardDto` is named to avoid colliding with the pre-existing `InterviewPrepDto`, which +belongs to the AI cache — a reminder that the two systems are genuinely different. + ## Extension points - **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven. @@ -313,4 +373,6 @@ storage, no duplicate upload path. Files stay private to the owning user. (Phase 5.3, all three deterministic and read-only). 4. ✅ Application assets — CV variant association, tailoring suggestions, cover letter workflow with version history, documents (Phase 5.4). +5. ✅ Interview and follow-up — user-owned interview preparation, follow-up over the existing + FollowUpAt and checklist, five more timeline event types (Phase 5.5). 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 c4a36e8..856dc0e 100644 --- a/docs/infrastructure/database-ownership.md +++ b/docs/infrastructure/database-ownership.md @@ -75,8 +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`, `CoverLetterVersions`, `TwoFactorRecoveryCodes`, `TrustedDevices`, -`UserSessions`. +`ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems`, `TwoFactorRecoveryCodes`, +`TrustedDevices`, `UserSessions`. No-op migrations, each with a comment explaining why: @@ -88,6 +88,7 @@ No-op migrations, each with a comment explaining why: | `20260719085904_AddApplicationChecklistItems` | `ApplicationChecklistItems` | | `20260719094728_SyncCareerChildKeyLengths` | snapshot sync only | | `20260719120954_AddCoverLetterVersions` | `CoverLetterVersions` | +| `20260719145044_AddInterviewPrepItems` | `InterviewPrepItems` | ### Dependency guards @@ -96,7 +97,7 @@ skips it on a fresh database and pass 2 creates it: | Table | Waits for | |---|---| -| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions` | `JobApplications` (migration-owned) | +| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems` | `JobApplications` (migration-owned) | | `CvVariantVersions` | `CvVariants` | | `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` | | `CvExtractionRuns` | `CvUploadArtifacts` | diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index b8dfa49..51db01c 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -234,6 +234,61 @@ export const applicationAssetsApi = { api.post(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data), }; +// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come +// from the existing /ai routes and only land here once accepted. +export type InterviewPrepItem = { + id: number; + category: string; + title: string; + content: string | null; + source: string; + isPrepared: boolean; + sortOrder: number; + updatedAtUtc: string; +}; + +export type InterviewPrepGroup = { category: string; label: string; items: InterviewPrepItem[] }; + +export type InterviewPrepBoard = { + groups: InterviewPrepGroup[]; + total: number; + prepared: number; + percent: number; + isInterviewStage: boolean; + aiSuggestionCount: number; +}; + +export type FollowUp = { + followUpAt: string | null; + nextAction: string | null; + responseReceived: boolean; + openFollowUpTasks: number; +}; + +export const INTERVIEW_PREP_CATEGORIES: { key: string; label: string }[] = [ + { key: "company-research", label: "Company research" }, + { key: "technical", label: "Technical preparation" }, + { key: "behavioural", label: "Behavioural questions" }, + { key: "star", label: "STAR examples" }, + { key: "question", label: "Questions to ask them" }, + { key: "note", label: "Notes" }, +]; + +export const interviewPrepApi = { + get: (jobId: number) => + api.get(`/jobapplications/${jobId}/interview-prep`).then((r) => r.data), + add: (jobId: number, body: { category?: string; title: string; content?: string; source?: string }) => + api.post(`/jobapplications/${jobId}/interview-prep`, body).then((r) => r.data), + update: (jobId: number, itemId: number, body: Partial>) => + api.patch(`/jobapplications/${jobId}/interview-prep/${itemId}`, body).then((r) => r.data), + remove: (jobId: number, itemId: number) => + api.delete(`/jobapplications/${jobId}/interview-prep/${itemId}`).then(() => undefined), + followUp: (jobId: number) => + api.get(`/jobapplications/${jobId}/follow-up`).then((r) => r.data), + setFollowUp: (jobId: number, followUpAt: string | null, nextAction?: string | null) => + api.put(`/jobapplications/${jobId}/follow-up`, { followUpAt, nextAction }).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/InterviewPrep.tsx b/job-tracker-ui/src/components/InterviewPrep.tsx new file mode 100644 index 0000000..5db5a61 --- /dev/null +++ b/job-tracker-ui/src/components/InterviewPrep.tsx @@ -0,0 +1,352 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { + Alert, Box, Button, Checkbox, Chip, Divider, IconButton, LinearProgress, MenuItem, Paper, + Skeleton, Stack, TextField, Tooltip, Typography, +} from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; + +import { getApiErrorMessage } from "../api"; +import { + FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi, +} from "../applicationWorkspace"; + +// Phase 5.5 — Interview preparation and follow-up. +// +// The prep content is the user's: this component never generates anything. AI suggestions live in the +// AI panel below and only become prep items when the user adds them. +// docs/architecture/application-workspace.md. + +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 + )} + + ); +} + +export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { + const [board, setBoard] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [title, setTitle] = useState(""); + const [category, setCategory] = useState(INTERVIEW_PREP_CATEGORIES[0].key); + + const load = useCallback(async () => { + setLoading(true); + try { + setBoard(await interviewPrepApi.get(jobId)); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not load interview preparation.")); + } finally { + setLoading(false); + } + }, [jobId]); + + useEffect(() => { + load(); + }, [load]); + + const mutate = async (run: () => Promise) => { + setBusy(true); + try { + await run(); + await load(); + } catch (err) { + setError(getApiErrorMessage(err, "Could not update interview preparation.")); + } finally { + setBusy(false); + } + }; + + const add = (e: React.FormEvent) => { + e.preventDefault(); + const value = title.trim(); + if (!value) return; + setTitle(""); + return mutate(() => interviewPrepApi.add(jobId, { category, title: value })); + }; + + return ( + + + + {board && !board.isInterviewStage && ( + + This application has not reached an interview stage yet. Preparing early is fine. + + )} + + {board && board.total > 0 && ( + + + + Preparation progress + + + {board.prepared} of {board.total} ready + + + + + )} + + {board && board.total === 0 ? ( + + Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR + example you want ready. + + ) : ( + (board?.groups ?? []).map((group) => ( + + + {group.label} + + + {group.items.map((item) => ( + + ))} + + + )) + )} + + + + setCategory(e.target.value)} + sx={{ minWidth: { sm: 200 } }} + > + {INTERVIEW_PREP_CATEGORIES.map((c) => ( + {c.label} + ))} + + setTitle(e.target.value)} + /> + + + + + + + + + ); +} + +// One prep entry. The answer is a local draft until saved, so a background reload never eats typing. +function PrepRow({ jobId, item, busy, onChanged, onError }: { + jobId: number; + item: InterviewPrepItem; + busy: boolean; + onChanged: () => void; + onError: (message: string) => void; +}) { + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const content = draft ?? item.content ?? ""; + const dirty = draft !== null && draft !== (item.content ?? ""); + + const run = async (fn: () => Promise) => { + setSaving(true); + try { + await fn(); + setDraft(null); + onChanged(); + } catch (err) { + onError(getApiErrorMessage(err, "Could not save this answer.")); + } finally { + setSaving(false); + } + }; + + return ( + + + run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))} + sx={{ mt: -0.5 }} + /> + + + {item.title} + {item.source === "ai" && ( + + )} + + setDraft(e.target.value)} + sx={{ mt: 0.75 }} + /> + {dirty && ( + + + + + )} + + + + run(() => interviewPrepApi.remove(jobId, item.id))} + > + + + + + + + ); +} + +export function ApplicationFollowUp({ jobId }: { jobId: number }) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [date, setDate] = useState(""); + const [action, setAction] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + try { + const result = await interviewPrepApi.followUp(jobId); + setData(result); + setDate(result.followUpAt ? result.followUpAt.slice(0, 10) : ""); + setAction(result.nextAction ?? ""); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not load follow-up.")); + } finally { + setLoading(false); + } + }, [jobId]); + + useEffect(() => { + load(); + }, [load]); + + const save = async () => { + setBusy(true); + try { + const result = await interviewPrepApi.setFollowUp(jobId, date || null, action || null); + setData(result); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not save the follow-up.")); + } finally { + setBusy(false); + } + }; + + return ( + + + {data && data.openFollowUpTasks > 0 && ( + + {data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on + the checklist. + + )} + + + setDate(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setAction(e.target.value)} + /> + + + + {data && !data.followUpAt && ( + + No follow-up scheduled. Applications without one go quiet. + + )} + + + ); +} diff --git a/job-tracker-ui/src/interview-prep.test.tsx b/job-tracker-ui/src/interview-prep.test.tsx new file mode 100644 index 0000000..fa9a0f3 --- /dev/null +++ b/job-tracker-ui/src/interview-prep.test.tsx @@ -0,0 +1,172 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep"; +import { api } from "./api"; + +jest.mock("./api", () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + put: jest.fn(), + delete: 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 board = { + groups: [ + { + category: "company-research", + label: "Company research", + items: [ + { id: 1, category: "company-research", title: "Funding history", content: "Series B in 2025.", source: "user", isPrepared: true, sortOrder: 1, updatedAtUtc: "2026-07-19T10:00:00Z" }, + ], + }, + { + category: "behavioural", + label: "Behavioural questions", + items: [ + { id: 2, category: "behavioural", title: "Tell me about a conflict", content: null, source: "ai", isPrepared: false, sortOrder: 2, updatedAtUtc: "2026-07-19T10:00:00Z" }, + ], + }, + ], + total: 2, + prepared: 1, + percent: 50, + isInterviewStage: true, + aiSuggestionCount: 1, +}; + +const followUp = { followUpAt: "2026-07-26T00:00:00", nextAction: "Chase recruiter", responseReceived: false, openFollowUpTasks: 1 }; + +function routeGet(overrides: Record = {}) { + mockedApi.get.mockImplementation((url: string) => { + if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any); + return Promise.resolve({ data: overrides.board ?? board } as any); + }); +} + +beforeEach(() => jest.clearAllMocks()); + +test("prep renders grouped items with progress and marks AI-sourced ones", async () => { + routeGet(); + + render(); + + // Each label appears twice: once as the group heading, once as a category option in the add form. + expect((await screen.findAllByText("Company research")).length).toBeGreaterThan(0); + expect(screen.getAllByText("Behavioural questions").length).toBeGreaterThan(0); + expect(screen.getByText("Funding history")).toBeInTheDocument(); + expect(screen.getByText("1 of 2 ready")).toBeInTheDocument(); + expect(screen.getByText("From AI")).toBeInTheDocument(); +}); + +test("adding a prep item posts the chosen category and title", async () => { + routeGet(); + mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any); + + render(); + fireEvent.change(await screen.findByLabelText(/Add a question, topic or note/i), { + target: { value: "What does success look like?" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( + "/jobapplications/7/interview-prep", + { category: "company-research", title: "What does success look like?" }, + )); +}); + +test("marking an item ready patches it", async () => { + routeGet(); + mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any); + + render(); + fireEvent.click(await screen.findByRole("checkbox", { name: "Ready: Tell me about a conflict" })); + + await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith( + "/jobapplications/7/interview-prep/2", { isPrepared: true })); +}); + +test("an answer is only saved when the user asks", async () => { + routeGet(); + mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any); + + render(); + const boxes = await screen.findAllByPlaceholderText(/Your answer, in your own words/i); + fireEvent.change(boxes[1], { target: { value: "My STAR answer" } }); + + // Typing alone must not persist anything. + expect(mockedApi.patch).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /Save answer/i })); + await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith( + "/jobapplications/7/interview-prep/2", { content: "My STAR answer" })); +}); + +test("deleting a prep item calls delete", async () => { + routeGet(); + mockedApi.delete.mockResolvedValue({ data: undefined } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" })); + + await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1")); +}); + +test("empty prep shows a useful empty state", async () => { + routeGet({ board: { groups: [], total: 0, prepared: 0, percent: 0, isInterviewStage: false, aiSuggestionCount: 0 } }); + + render(); + + expect(await screen.findByText(/Nothing prepared yet/i)).toBeInTheDocument(); + expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument(); +}); + +test("prep surfaces a load error", async () => { + mockedApi.get.mockRejectedValue(new Error("boom")); + + render(); + + expect(await screen.findByText(/Could not load interview preparation/i)).toBeInTheDocument(); +}); + +// ---------- follow-up ---------- + +test("follow-up loads the existing date and open checklist tasks", async () => { + routeGet(); + + render(); + + expect(await screen.findByDisplayValue("2026-07-26")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Chase recruiter")).toBeInTheDocument(); + expect(screen.getByText(/1 open follow-up task on the checklist/i)).toBeInTheDocument(); +}); + +test("saving a follow-up sends the date and next action", async () => { + routeGet(); + mockedApi.put.mockResolvedValue({ data: followUp } as any); + + render(); + fireEvent.change(await screen.findByLabelText(/Follow up on/i), { target: { value: "2026-08-01" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( + "/jobapplications/7/follow-up", + { followUpAt: "2026-08-01", nextAction: "Chase recruiter" }, + )); +}); + +test("no follow-up shows an empty state", async () => { + routeGet({ followUp: { followUpAt: null, nextAction: null, responseReceived: false, openFollowUpTasks: 0 } }); + + render(); + + expect(await screen.findByText(/No follow-up scheduled/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 04ba6b3..717a98b 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -25,6 +25,7 @@ import { import { ApplicationCoverLetterSection, ApplicationCvSection, } from "../components/ApplicationAssets"; +import { ApplicationInterviewPrep } from "../components/InterviewPrep"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, } from "../applicationWorkspace"; @@ -102,6 +103,7 @@ export default function ApplicationWorkspacePage() { {section === "analysis" && jobId > 0 && } {section === "match" && jobId > 0 && } {section === "timeline" && jobId > 0 && } + {section === "interview" && jobId > 0 && } {(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (