From f299d7be7c83b332a38829d368bc18d768864e35 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 15:17:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20AI=20Workspace=20per=20job=20applic?= =?UTF-8?q?ation=20=E2=80=94=20modules=20+=20append-only=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 backend. A unified AI Workspace for each application, orchestrating the five suggestion modules through the existing ISummarizerService provider abstraction and storing every generation as append-only history (AiInteraction) so outputs can be reused, compared, and deleted — distinct from the existing AiWorkspaceNote cache (one row, overwritten). Modules (all suggestion-only, "never invent facts" guardrail, never mutate the profile/variant/application): job-analysis, career-match, cover-letter (6 modes), interview, application-review. Each builds a prompt from the job + master profile text and returns markdown. - Models/AiInteraction.cs + migration AddAiInteractions (verified on container) - Services/AiWorkspaceService.cs (prompts, history, delete) - Controllers/AiWorkspaceController.cs (/api/jobapplications/{id}/ai: generate, history, delete, modules+provider) - 7 tests (store, history filter/order, delete, mode normalization, unknown module, empty output, tenant scoping); 306 backend green Co-Authored-By: Claude Opus 4.8 --- Data/JobTrackerContext.cs | 14 + JobTrackerApi.Tests/AiWorkspaceTests.cs | 141 ++ .../Controllers/AiWorkspaceController.cs | 90 + ...260718131138_AddAiInteractions.Designer.cs | 2119 +++++++++++++++++ .../20260718131138_AddAiInteractions.cs | 58 + .../JobTrackerContextModelSnapshot.cs | 55 + JobTrackerApi/Program.cs | 1 + JobTrackerApi/Services/AiWorkspaceService.cs | 185 ++ Models/AiInteraction.cs | 32 + 9 files changed, 2695 insertions(+) create mode 100644 JobTrackerApi.Tests/AiWorkspaceTests.cs create mode 100644 JobTrackerApi/Controllers/AiWorkspaceController.cs create mode 100644 JobTrackerApi/Migrations/20260718131138_AddAiInteractions.Designer.cs create mode 100644 JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs create mode 100644 JobTrackerApi/Services/AiWorkspaceService.cs create mode 100644 Models/AiInteraction.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index cda94fd..20f755b 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -44,6 +44,7 @@ namespace JobTrackerApi.Data public DbSet AiWorkspaceNotes => Set(); public DbSet CvVariants => Set(); public DbSet CvVariantVersions => Set(); + public DbSet AiInteractions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -317,6 +318,19 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.CvVariantId) .OnDelete(DeleteBehavior.Cascade); + + // Phase 5: append-only AI interaction history per job application. Same deny-on-null tenant + // filter; indexed for the per-job history read; cascades with the application. + // docs/architecture/ai-career-assistant.md. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc }); + 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/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs new file mode 100644 index 0000000..d4b85f3 --- /dev/null +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -0,0 +1,141 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class AiWorkspaceTests +{ + private sealed class FakeAi : ISummarizerService + { + public string? Next = "## Result\nGenerated suggestion."; + public int Calls; + public string? LastInstruction; + public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) + { + Calls++; + LastInstruction = instruction; + return Task.FromResult(Next); + } + public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); + public Task ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); + } + + private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) 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); + var ai = new FakeAi(); + return (db, new AiWorkspaceService(db, ai), ai); + } + + private static async Task SeedJobAsync(JobTrackerContext db, string owner) + { + var company = new Company { OwnerUserId = owner, Name = "Acme" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication { OwnerUserId = owner, CompanyId = company.Id, JobTitle = "Senior Engineer", Status = "Applied", Description = "Build things with C#." }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job.Id; + } + + private static AiGenerateRequest Req(string module, string? mode = null) => new(module, mode, null); + + [Fact] + public async Task Generate_stores_an_interaction_and_returns_it() + { + var (db, svc, _) = New("user-1"); + await using var _ = db; + var jobId = await SeedJobAsync(db, "user-1"); + + var res = await svc.GenerateAsync("user-1", jobId, "My CV text", "Ada", Req("job-analysis"), "gemini", default); + + Assert.NotNull(res); + Assert.Equal("job-analysis", res!.Module); + Assert.Equal("gemini", res.Provider); + Assert.Contains("Generated suggestion", res.ResultJson); + Assert.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync()); + } + + [Fact] + public async Task Cover_letter_normalizes_an_unknown_mode_and_labels_the_title() + { + var (db, svc, _) = New("user-1"); + await using var _ = db; + var jobId = await SeedJobAsync(db, "user-1"); + + var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter", "banana"), "p", default); + + Assert.Equal("professional", res!.Mode); + Assert.Contains("Professional", res.Title); + } + + [Fact] + public async Task History_is_newest_first_and_filters_by_module() + { + var (db, svc, _) = New("user-1"); + await using var _ = db; + var jobId = await SeedJobAsync(db, "user-1"); + await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("job-analysis"), "p", default); + await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("career-match"), "p", default); + await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("career-match"), "p", default); + + var all = await svc.HistoryAsync("user-1", jobId, null, default); + Assert.Equal(3, all.Count); + var match = await svc.HistoryAsync("user-1", jobId, "career-match", default); + Assert.Equal(2, match.Count); + Assert.All(match, m => Assert.Equal("career-match", m.Module)); + } + + [Fact] + public async Task Delete_removes_only_the_owner_row() + { + var (db, svc, _) = New("user-1"); + await using var _ = db; + var jobId = await SeedJobAsync(db, "user-1"); + var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("interview"), "p", default); + + Assert.True(await svc.DeleteAsync("user-1", res!.Id, default)); + Assert.Null(await svc.GetAsync("user-1", res.Id, default)); + Assert.False(await svc.DeleteAsync("user-1", res.Id, default)); + } + + [Fact] + public async Task Unknown_module_is_rejected() + { + var (db, svc, _) = New("user-1"); + await using var _ = db; + var jobId = await SeedJobAsync(db, "user-1"); + await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("write-my-life-story"), "p", default)); + } + + [Fact] + public async Task Empty_ai_output_raises_unavailable() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + ai.Next = " "; + var jobId = await SeedJobAsync(db, "user-1"); + await Assert.ThrowsAsync(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter"), "p", default)); + } + + [Fact] + public async Task Another_users_job_is_not_found() + { + var (db, svc, _) = New("user-1"); + await using var _ = db; + var otherJob = await SeedJobAsync(db, "user-2"); + + var res = await svc.GenerateAsync("user-1", otherJob, "cv", "Ada", Req("job-analysis"), "p", default); + Assert.Null(res); + } +} diff --git a/JobTrackerApi/Controllers/AiWorkspaceController.cs b/JobTrackerApi/Controllers/AiWorkspaceController.cs new file mode 100644 index 0000000..ba068b9 --- /dev/null +++ b/JobTrackerApi/Controllers/AiWorkspaceController.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Phase 5 — the AI Workspace for one job application. Every module runs through ISummarizerService and +// is stored as append-only history; nothing is applied automatically. docs/architecture/ai-career-assistant.md. +[ApiController] +[Route("api/jobapplications/{jobId:int}/ai")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class AiWorkspaceController : ControllerBase +{ + private readonly UserManager _users; + private readonly IAiWorkspaceService _workspace; + private readonly IConfiguration _config; + + public AiWorkspaceController(UserManager users, IAiWorkspaceService workspace, IConfiguration config) + { + _users = users; + _workspace = workspace; + _config = config; + } + + public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext); + public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, DateTimeOffset CreatedAtUtc); + + [HttpGet("modules")] + public ActionResult Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() }); + + [HttpPost("generate")] + public async Task> Generate(int jobId, [FromBody] GenerateRequest request, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module."); + + try + { + var interaction = await _workspace.GenerateAsync( + user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user), + new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct); + return interaction is null ? NotFound() : Ok(ToDto(interaction)); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (AiUnavailableException ex) + { + return StatusCode(StatusCodes.Status502BadGateway, ex.Message); + } + } + + [HttpGet("history")] + public async Task>> History(int jobId, [FromQuery] string? module, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var history = await _workspace.HistoryAsync(user.Id, jobId, module, ct); + return Ok(history.Select(ToDto)); + } + + [HttpDelete("history/{id:int}")] + public async Task Delete(int jobId, int id, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + return await _workspace.DeleteAsync(user.Id, id, ct) ? NoContent() : NotFound(); + } + + private string ResolveProvider() => + _config["Ai:Provider"] ?? Environment.GetEnvironmentVariable("AI_PROVIDER") ?? "ai-service"; + + private static string ResolveName(ApplicationUser user) + { + var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim(); + return name ?? string.Empty; + } + + private static InteractionDto ToDto(AiInteraction x) => new( + x.Id, x.Module, x.Mode, x.Title, x.Provider, + JsonSerializer.Deserialize(string.IsNullOrWhiteSpace(x.ResultJson) ? "{}" : x.ResultJson), + x.CreatedAtUtc); +} diff --git a/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.Designer.cs b/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.Designer.cs new file mode 100644 index 0000000..f03fa86 --- /dev/null +++ b/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.Designer.cs @@ -0,0 +1,2119 @@ +// +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("20260718131138_AddAiInteractions")] + partial class AddAiInteractions + { + /// + 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() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .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.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AvatarImageDataUrl") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentCvExtractionRunId") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvUploadArtifactId") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("GoogleEmail") + .HasColumnType("TEXT"); + + b.Property("GoogleLinkedAt") + .HasColumnType("TEXT"); + + b.Property("GoogleSubject") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfileCvStructureJson") + .HasColumnType("TEXT"); + + b.Property("ProfileCvText") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Purpose") + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .HasColumnType("TEXT"); + + b.Property("UseForAi") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateNormalized") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerCertifications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Institution") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualification") + .HasColumnType("TEXT"); + + b.Property("QualificationLevel") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerEducations"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Company") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerExperiences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerLanguages"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LongTailJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId") + .IsUnique(); + + b.ToTable("CareerProfiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "Version"); + + b.ToTable("CareerProfileVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerProjects"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Proficiency") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerSkills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastContactedAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextContactAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PipelineStage") + .HasColumnType("TEXT"); + + b.Property("RecruiterEmail") + .HasColumnType("TEXT"); + + b.Property("RecruiterLinkedIn") + .HasColumnType("TEXT"); + + b.Property("RecruiterName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentMetadataJson") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("ExternalFrom") + .HasColumnType("TEXT"); + + b.Property("ExternalLabelsJson") + .HasColumnType("TEXT"); + + b.Property("ExternalMessageId") + .HasColumnType("TEXT"); + + b.Property("ExternalThreadId") + .HasColumnType("TEXT"); + + b.Property("ExternalTo") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Correspondences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ArtifactId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LlmPromptVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedText") + .HasColumnType("TEXT"); + + b.Property("NormalizerVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawExtractedText") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StructuredProfileJson") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtifactId"); + + b.HasIndex("OwnerUserId", "StartedAtUtc"); + + b.ToTable("CvExtractionRuns"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "UploadedAtUtc"); + + b.ToTable("CvUploadArtifacts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("PublicSlug") + .IsUnique(); + + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + + b.ToTable("CvVariants"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CvVariantId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CvVariantId", "Version"); + + b.ToTable("CvVariantVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GmailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "GmailAddress") + .IsUnique(); + + b.ToTable("GmailConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GmailReviewDecisions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UseSsl") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LikelyQuestionsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TalkingPointsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WeakSpotsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("InterviewPrepNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CoverLetterText") + .HasColumnType("TEXT"); + + b.Property("DateApplied") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("FeedbackRequestedAt") + .HasColumnType("TEXT"); + + b.Property("FollowUpAt") + .HasColumnType("TEXT"); + + b.Property("HasCoverLetter") + .HasColumnType("INTEGER"); + + b.Property("HasOtherAttachment") + .HasColumnType("INTEGER"); + + b.Property("HasPortfolio") + .HasColumnType("INTEGER"); + + b.Property("HasResume") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("LastReminderEmailSentAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("NextAction") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("RecruiterMessageDraft") + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseReceived") + .HasColumnType("INTEGER"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TailoredCvText") + .HasColumnType("TEXT"); + + b.Property("TailoredCvUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "FollowUpAt"); + + b.HasIndex("OwnerUserId", "IsDeleted"); + + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NewValue") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OldValue") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MicrosoftGraphConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RuleSettings"); + + b.HasData( + new + { + Id = 1, + AppliedFollowUpDays = 14, + AppliedGhostDays = 30, + FeedbackFollowUpDays = 7, + FeedbackGhostDays = 14, + OfferFollowUpDays = 7, + OfferGhostDays = 14 + }); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SystemEmailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasColumnType("TEXT"); + + b.Property("SmtpEnableSsl") + .HasColumnType("INTEGER"); + + b.Property("SmtpHost") + .HasColumnType("TEXT"); + + b.Property("SmtpPassword") + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpTimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("SmtpUser") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemEmailSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CustomSectionsJson") + .HasColumnType("TEXT"); + + b.Property("EducationJson") + .HasColumnType("TEXT"); + + b.Property("ExperienceJson") + .HasColumnType("TEXT"); + + b.Property("GenerationContextHash") + .HasColumnType("TEXT"); + + b.Property("Headline") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LastEditedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastGeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RenderOptionsJson") + .HasColumnType("TEXT"); + + b.Property("SelectedSkillsJson") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SummaryJson") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => + { + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("OwnerUserId"); + + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.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.Attachment", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Attachments") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Certifications") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Education") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Experiences") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Languages") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany() + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Projects") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Skills") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Messages") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") + .WithMany() + .HasForeignKey("ArtifactId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Artifact"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.HasOne("JobTrackerApi.Models.CvVariant", "CvVariant") + .WithMany() + .HasForeignKey("CvVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CvVariant"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany("Jobs") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.Job", "Job") + .WithMany("Applications") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Company"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Events") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithOne("TailoredCvDraft") + .HasForeignKey("JobTrackerApi.Models.TailoredCvDraft", "JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Navigation("Certifications"); + + b.Navigation("Education"); + + b.Navigation("Experiences"); + + b.Navigation("Languages"); + + b.Navigation("Projects"); + + b.Navigation("Skills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Navigation("Attachments"); + + b.Navigation("Events"); + + b.Navigation("Messages"); + + b.Navigation("TailoredCvDraft"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs b/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs new file mode 100644 index 0000000..dced402 --- /dev/null +++ b/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddAiInteractions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiInteractions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + OwnerUserId = table.Column(type: "TEXT", nullable: false), + JobApplicationId = table.Column(type: "INTEGER", nullable: false), + Module = table.Column(type: "TEXT", nullable: false), + Mode = table.Column(type: "TEXT", nullable: true), + Title = table.Column(type: "TEXT", nullable: false), + Provider = table.Column(type: "TEXT", nullable: false), + ResultJson = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AiInteractions", x => x.Id); + table.ForeignKey( + name: "FK_AiInteractions_JobApplications_JobApplicationId", + column: x => x.JobApplicationId, + principalTable: "JobApplications", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AiInteractions_JobApplicationId", + table: "AiInteractions", + column: "JobApplicationId"); + + migrationBuilder.CreateIndex( + name: "IX_AiInteractions_OwnerUserId_JobApplicationId_Module_CreatedAtUtc", + table: "AiInteractions", + columns: new[] { "OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AiInteractions"); + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index e652aee..505c5f7 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -17,6 +17,50 @@ namespace JobTrackerApi.Migrations #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() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .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") @@ -1766,6 +1810,17 @@ namespace JobTrackerApi.Migrations 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") diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index ae0240a..4eba639 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -40,6 +40,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/AiWorkspaceService.cs b/JobTrackerApi/Services/AiWorkspaceService.cs new file mode 100644 index 0000000..7d79d7e --- /dev/null +++ b/JobTrackerApi/Services/AiWorkspaceService.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public sealed record AiGenerateRequest(string Module, string? Mode, string? ExtraContext); + +// Thrown when the AI service returns nothing usable — the controller maps it to 502 with the reason. +public sealed class AiUnavailableException : Exception +{ + public AiUnavailableException(string message) : base(message) { } +} + +public interface IAiWorkspaceService +{ + // Runs one module, stores the result as an append-only AiInteraction, and returns it. Never + // mutates the profile, a CV variant, or the application — suggestion only. + Task GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct); + Task> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct); + Task GetAsync(string ownerUserId, int id, CancellationToken ct); + Task DeleteAsync(string ownerUserId, int id, CancellationToken ct); + + // The module keys this service supports (for the controller/UI to enumerate). + IReadOnlyList Modules { get; } +} + +public sealed class AiWorkspaceService : IAiWorkspaceService +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public IReadOnlyList Modules { get; } = new[] + { + "job-analysis", "career-match", "cover-letter", "interview", "application-review", + }; + + private static readonly HashSet CoverLetterModes = new(StringComparer.OrdinalIgnoreCase) + { + "professional", "friendly", "short", "detailed", "modern", "traditional", + }; + + private const string Guardrail = + "Preserve every factual claim — never invent employers, titles, dates, qualifications, or metrics. " + + "This is a suggestion the user will review and edit; return only the requested content, in clean markdown, with no preamble."; + + private readonly JobTrackerContext _db; + private readonly ISummarizerService _ai; + + public AiWorkspaceService(JobTrackerContext db, ISummarizerService ai) + { + _db = db; + _ai = ai; + } + + public async Task GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct) + { + var module = (req.Module ?? string.Empty).Trim().ToLowerInvariant(); + if (!Modules.Contains(module)) throw new ArgumentException($"Unknown AI module '{module}'."); + + var job = await _db.JobApplications.Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + var jobText = BuildJobContext(job); + var profile = string.IsNullOrWhiteSpace(profileText) ? "(no master profile on file yet)" : profileText.Trim(); + var mode = NormalizeMode(module, req.Mode); + var extra = string.IsNullOrWhiteSpace(req.ExtraContext) ? string.Empty : $"\n\nAdditional user context:\n{req.ExtraContext.Trim()}"; + + var (instruction, source, title, max) = module switch + { + "job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 1000), + "career-match" => (CareerMatchPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Career match", 1000), + "cover-letter" => (CoverLetterPrompt(mode!, candidateName), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", $"Cover letter · {Capitalize(mode!)}", 900), + "interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Interview prep", 1100), + "application-review" => (ApplicationReviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Application review", 900), + _ => throw new ArgumentException($"Unknown AI module '{module}'."), + }; + + var result = await _ai.SummarizeSectionAsync($"{instruction} {Guardrail}", source, max, 120); + if (string.IsNullOrWhiteSpace(result)) + { + throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment."); + } + + var interaction = new AiInteraction + { + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Module = module, + Mode = mode, + Title = title, + Provider = string.IsNullOrWhiteSpace(provider) ? "ai-service" : provider, + ResultJson = JsonSerializer.Serialize(new { text = result.Trim() }, Json), + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + _db.AiInteractions.Add(interaction); + await _db.SaveChangesAsync(ct); + return interaction; + } + + public async Task> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct) + { + var q = _db.AiInteractions.Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId); + if (!string.IsNullOrWhiteSpace(module)) { var m = module.Trim().ToLowerInvariant(); q = q.Where(x => x.Module == m); } + return await q.OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct); + } + + public Task GetAsync(string ownerUserId, int id, CancellationToken ct) => + _db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct); + + public async Task DeleteAsync(string ownerUserId, int id, CancellationToken ct) + { + var row = await GetAsync(ownerUserId, id, ct); + if (row is null) return false; + _db.AiInteractions.Remove(row); + await _db.SaveChangesAsync(ct); + return true; + } + + private static string? NormalizeMode(string module, string? mode) + { + if (module != "cover-letter") return null; + var m = (mode ?? "professional").Trim().ToLowerInvariant(); + return CoverLetterModes.Contains(m) ? m : "professional"; + } + + private static string BuildJobContext(JobApplication job) + { + var parts = new[] + { + Field("Role", job.JobTitle), + Field("Company", job.Company?.Name), + Field("Status", job.Status), + Field("Summary", job.ShortSummary), + Field("Description", job.Description), + Field("Translated description", job.TranslatedDescription), + Field("Notes", job.Notes), + Field("URL", job.JobUrl), + }; + return string.Join("\n", parts.Where(p => p != null)); + } + + private static string? Field(string label, string? value) => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}"; + private static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; + + // --- Prompts. Each asks for markdown with clear sections; the guardrail is appended by the caller. --- + + private static string JobAnalysisPrompt() => + "Analyse this job advert. Return markdown with these sections: **Company**, **Role**, **Required skills**, " + + "**Nice-to-have skills**, **Technologies**, **Experience**, **Education**, **Soft skills**, **Responsibilities**, " + + "**Salary** (only if stated), **Benefits**, **Work model**, **Visa requirements**, **Language requirements**, " + + "**Summary** (2–3 sentences), **Likely interview topics**, and **Confidence** (High/Medium/Low with one line on why). " + + "Omit any field the advert does not mention rather than guessing."; + + private static string CareerMatchPrompt() => + "Compare the candidate profile against the job advert. Return markdown with: **Match** (a single percentage with one " + + "line of reasoning), **Strengths**, **Weaknesses**, **Missing skills**, **Most relevant experience**, and " + + "**Suggested improvements** (concrete, actionable). Base every point only on what the profile actually shows."; + + private static string CoverLetterPrompt(string mode, string candidateName) => + $"Write a cover letter for {(string.IsNullOrWhiteSpace(candidateName) ? "the candidate" : candidateName)} for this role in a " + + $"{ModeGuidance(mode)} Ground every claim in the candidate profile; do not invent experience. Return only the letter body."; + + private static string ModeGuidance(string mode) => mode switch + { + "friendly" => "warm, personable style — approachable but still professional.", + "short" => "concise style — 3 short paragraphs at most, every sentence earning its place.", + "detailed" => "thorough style — cover motivation, the strongest matching experience, and fit, without padding.", + "modern" => "modern, direct style — confident, plain language, no clichés.", + "traditional" => "traditional, formal style — conventional structure and measured tone.", + _ => "professional, confident style.", + }; + + private static string InterviewPrompt() => + "Create an interview preparation brief in markdown with: **Company research summary** (from the advert only), " + + "**Likely interview questions**, **Behavioural questions**, **Technical questions**, **Suggested STAR answers** " + + "(outline Situation/Task/Action/Result using the candidate's real experience), and a **Preparation checklist**."; + + private static string ApplicationReviewPrompt() => + "Review this application (candidate profile as the material to be submitted, against the job advert). Return markdown " + + "with: **Overall strength** (a one-line verdict + rating out of 10), **Missing information**, **Weak areas**, " + + "**ATS issues** (keywords/formatting that could hurt automated screening), **Grammar & clarity**, and " + + "**Formatting suggestions**. Be specific and constructive."; +} diff --git a/Models/AiInteraction.cs b/Models/AiInteraction.cs new file mode 100644 index 0000000..adbceb7 --- /dev/null +++ b/Models/AiInteraction.cs @@ -0,0 +1,32 @@ +namespace JobTrackerApi.Models; + +// Phase 5 — AI Career Assistant. Append-only history of every AI interaction for a job application. +// Unlike AiWorkspaceNote (one row per (owner, job, type), overwritten on regenerate — a cache), this +// keeps EVERY generation so the user can restore, compare, reuse, or delete past outputs. Suggestion +// only: an interaction never mutates the master profile, a CV variant, or the application itself. +// docs/architecture/ai-career-assistant.md. +public sealed class AiInteraction +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + + // job-analysis | career-match | cover-letter | interview | application-review + public string Module { get; set; } = string.Empty; + + // Optional mode within a module (e.g. cover-letter: professional|friendly|short|detailed|modern|traditional). + public string? Mode { get; set; } + + // Human label for the history list, e.g. "Cover letter · Professional". + public string Title { get; set; } = string.Empty; + + // Resolved provider label at generation time (transparency for the history list). + public string Provider { get; set; } = string.Empty; + + // The generated suggestion. { text: string, meta?: object } — text is markdown the UI renders; + // meta carries any structured extras (e.g. career-match percent). + public string ResultJson { get; set; } = string.Empty; + + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +}