From 653f011be217e1c5d5a020379e9b3e20d7faa489 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 9 Aug 2026 23:32:27 +0200 Subject: [PATCH] feat(email): add durable send ledger Tracks only idempotency and delivery metadata; recipient, subject, and body are excluded. No provider send path is enabled. --- .../EmailSendAttemptStoreTests.cs | 104 + JobTrackerApi/Data/JobTrackerContext.cs | 21 + ...809195014_AddEmailSendAttempts.Designer.cs | 2635 +++++++++++++++++ .../20260809195014_AddEmailSendAttempts.cs | 91 + .../JobTrackerContextModelSnapshot.cs | 74 + JobTrackerApi/Models/EmailSendAttempt.cs | 27 + JobTrackerApi/Program.cs | 1 + .../Services/EmailSendAttemptStore.cs | 124 + 8 files changed, 3077 insertions(+) create mode 100644 JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs create mode 100644 JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.Designer.cs create mode 100644 JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.cs create mode 100644 JobTrackerApi/Models/EmailSendAttempt.cs create mode 100644 JobTrackerApi/Services/EmailSendAttemptStore.cs diff --git a/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs b/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs new file mode 100644 index 0000000..656a3f7 --- /dev/null +++ b/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs @@ -0,0 +1,104 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class EmailSendAttemptStoreTests +{ + [Fact] + public async Task Creation_is_idempotent_and_rejects_payload_reuse() + { + await using var fixture = await Fixture.CreateAsync(); + var store = fixture.Store("user-1"); + var requestId = Guid.NewGuid().ToString(); + var first = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default); + var duplicate = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default); + + Assert.True(first.Created); + Assert.False(duplicate.Created); + Assert.Equal(first.Attempt.Id, duplicate.Attempt.Id); + await Assert.ThrowsAsync(() => + store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('b')), default)); + } + + [Fact] + public async Task State_machine_never_restarts_terminal_or_uncertain_attempts() + { + await using var fixture = await Fixture.CreateAsync(); + var store = fixture.Store("user-1"); + var created = await store.CreateAsync(new(fixture.JobId, "microsoft", Guid.NewGuid().ToString(), Hash('c')), default); + + Assert.Equal(1, await store.BeginAsync(created.Attempt.Id, default)); + Assert.Equal(0, await store.BeginAsync(created.Attempt.Id, default)); + Assert.Equal(1, await store.MarkUncertainAsync(created.Attempt.Id, "transport_interrupted", default)); + Assert.Equal(0, await store.MarkSentAsync(created.Attempt.Id, "late-message", default)); + + var stored = await store.GetAsync(created.Attempt.Id, default); + Assert.Equal(EmailSendStatuses.Uncertain, stored!.Status); + Assert.Equal("transport_interrupted", stored.FailureCategory); + Assert.NotNull(stored.CompletedAtUtc); + } + + [Fact] + public async Task Direct_attempt_ids_are_tenant_scoped_and_store_no_message_content() + { + await using var fixture = await Fixture.CreateAsync(); + var ownerStore = fixture.Store("user-1"); + var created = await ownerStore.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('d')), default); + + Assert.Null(await fixture.Store("user-2").GetAsync(created.Attempt.Id, default)); + Assert.DoesNotContain(typeof(EmailSendAttempt).GetProperties(), property => + property.Name.Contains("Subject", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("Body", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase)); + } + + private static string Hash(char value) => new(value, 64); + + private sealed class Fixture(SqliteConnection connection, DbContextOptions options, int jobId) : IAsyncDisposable + { + private readonly List contexts = new(); + public int JobId { get; } = jobId; + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var db = CreateDb(options, "user-1"); + await db.Database.EnsureCreatedAsync(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return new Fixture(connection, options, job.Id); + } + + public EmailSendAttemptStore Store(string userId) + { + var db = CreateDb(options, userId); + contexts.Add(db); + return new EmailSendAttemptStore(db, TimeProvider.System); + } + + private static JobTrackerContext CreateDb(DbContextOptions options, string userId) + { + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns(userId); + return new JobTrackerContext(options, currentUser.Object); + } + + public async ValueTask DisposeAsync() + { + foreach (var context in contexts) await context.DisposeAsync(); + await connection.DisposeAsync(); + } + } +} diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index de60168..ae02278 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -61,6 +61,7 @@ namespace JobTrackerApi.Data public DbSet InterviewPrepItems => Set(); public DbSet UserOperations => Set(); public DbSet UserNotifications => Set(); + public DbSet EmailSendAttempts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -259,6 +260,26 @@ namespace JobTrackerApi.Data .HasForeignKey(x => x.OperationId) .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.Provider).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.ClientRequestId).HasMaxLength(128); + modelBuilder.Entity().Property(x => x.PayloadHash).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.ProviderMessageId).HasMaxLength(256); + modelBuilder.Entity().Property(x => x.FailureCategory).HasMaxLength(64); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.ClientRequestId }) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.CreatedAtUtc }); + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); diff --git a/JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.Designer.cs b/JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.Designer.cs new file mode 100644 index 0000000..851f069 --- /dev/null +++ b/JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.Designer.cs @@ -0,0 +1,2635 @@ +// +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("20260809195014_AddEmailSendAttempts")] + partial class AddEmailSendAttempts + { + /// + 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("EstimatedTokenCount") + .HasColumnType("INTEGER"); + + b.Property("InputCharacterCount") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("TEXT"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OutputCharacterCount") + .HasColumnType("INTEGER"); + + 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("AiEnabled") + .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("ExternalAiProcessingAllowed") + .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("MicrosoftObjectId") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("MicrosoftTenantId") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PendingEmail") + .HasMaxLength(320) + .HasColumnType("TEXT"); + + b.Property("PendingEmailRequestedAtUtc") + .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("StripeCustomerId") + .HasColumnType("TEXT"); + + b.Property("StripeLastEventCreatedUtc") + .HasColumnType("TEXT"); + + b.Property("StripeSubscriptionId") + .HasColumnType("TEXT"); + + b.Property("StripeSubscriptionStatus") + .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.HasIndex("MicrosoftTenantId", "MicrosoftObjectId") + .IsUnique(); + + 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.EmailSendAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientRequestId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("FailureCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "ClientRequestId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "CreatedAtUtc"); + + b.ToTable("EmailSendAttempts"); + }); + + 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.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DismissedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LinkPath") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("OperationId") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ReadAtUtc") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "DismissedAtUtc", "ReadAtUtc", "CreatedAtUtc"); + + b.ToTable("UserNotifications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AvailableAtUtc") + .HasColumnType("TEXT"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeadlineAtUtc") + .HasColumnType("TEXT"); + + b.Property("EntitlementDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("FailureCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("FailureMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastHeartbeatAtUtc") + .HasColumnType("TEXT"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LeaseToken") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("PrivacyPolicy") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProgressPercent") + .HasColumnType("INTEGER"); + + b.Property("ProgressStage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ResultReference") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "TaskType", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("Status", "AvailableAtUtc", "Priority"); + + b.ToTable("UserOperations"); + }); + + 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.EmailSendAttempt", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + 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("JobTrackerApi.Models.UserNotification", b => + { + b.HasOne("JobTrackerApi.Models.UserOperation", "Operation") + .WithOne() + .HasForeignKey("JobTrackerApi.Models.UserNotification", "OperationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Operation"); + }); + + 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/20260809195014_AddEmailSendAttempts.cs b/JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.cs new file mode 100644 index 0000000..65513fe --- /dev/null +++ b/JobTrackerApi/Migrations/20260809195014_AddEmailSendAttempts.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddEmailSendAttempts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase)) + { + migrationBuilder.Sql(""" + CREATE TABLE `EmailSendAttempts` ( + `Id` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, + `Provider` varchar(32) NOT NULL, + `ClientRequestId` varchar(128) NOT NULL, + `PayloadHash` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `Status` varchar(32) NOT NULL, + `ProviderMessageId` varchar(256) NULL, + `FailureCategory` varchar(64) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `StartedAtUtc` datetime(6) NULL, + `CompletedAtUtc` datetime(6) NULL, + CONSTRAINT `PK_EmailSendAttempts` PRIMARY KEY (`Id`), + CONSTRAINT `FK_EmailSendAttempts_JobApplications_JobApplicationId` + FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + """); + } + else + { + migrationBuilder.CreateTable( + name: "EmailSendAttempts", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OwnerUserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + JobApplicationId = table.Column(type: "INTEGER", nullable: false), + Provider = table.Column(type: "TEXT", maxLength: 32, nullable: false), + ClientRequestId = table.Column(type: "TEXT", maxLength: 128, nullable: false), + PayloadHash = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + ProviderMessageId = table.Column(type: "TEXT", maxLength: 256, nullable: true), + FailureCategory = table.Column(type: "TEXT", maxLength: 64, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + StartedAtUtc = table.Column(type: "TEXT", nullable: true), + CompletedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailSendAttempts", x => x.Id); + table.ForeignKey( + name: "FK_EmailSendAttempts_JobApplications_JobApplicationId", + column: x => x.JobApplicationId, + principalTable: "JobApplications", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + migrationBuilder.CreateIndex( + name: "IX_EmailSendAttempts_JobApplicationId", + table: "EmailSendAttempts", + column: "JobApplicationId"); + + migrationBuilder.CreateIndex( + name: "IX_EmailSendAttempts_OwnerUserId_ClientRequestId", + table: "EmailSendAttempts", + columns: new[] { "OwnerUserId", "ClientRequestId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_EmailSendAttempts_OwnerUserId_CreatedAtUtc", + table: "EmailSendAttempts", + columns: new[] { "OwnerUserId", "CreatedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailSendAttempts"); + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 46e0428..8b913a9 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -1098,6 +1098,69 @@ namespace JobTrackerApi.Migrations b.ToTable("CvVariantVersions"); }); + modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientRequestId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("FailureCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "ClientRequestId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "CreatedAtUtc"); + + b.ToTable("EmailSendAttempts"); + }); + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => { b.Property("Id") @@ -2383,6 +2446,17 @@ namespace JobTrackerApi.Migrations b.Navigation("CvVariant"); }); + modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b => { b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") diff --git a/JobTrackerApi/Models/EmailSendAttempt.cs b/JobTrackerApi/Models/EmailSendAttempt.cs new file mode 100644 index 0000000..623537b --- /dev/null +++ b/JobTrackerApi/Models/EmailSendAttempt.cs @@ -0,0 +1,27 @@ +namespace JobTrackerApi.Models; + +public static class EmailSendStatuses +{ + public const string Pending = "pending"; + public const string Sending = "sending"; + public const string Sent = "sent"; + public const string Failed = "failed"; + public const string Uncertain = "uncertain"; +} + +public sealed class EmailSendAttempt +{ + public Guid Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication JobApplication { get; set; } = null!; + public string Provider { get; set; } = string.Empty; + public string ClientRequestId { get; set; } = string.Empty; + public string PayloadHash { get; set; } = string.Empty; + public string Status { get; set; } = EmailSendStatuses.Pending; + public string? ProviderMessageId { get; set; } + public string? FailureCategory { get; set; } + public DateTime CreatedAtUtc { get; set; } + public DateTime? StartedAtUtc { get; set; } + public DateTime? CompletedAtUtc { get; set; } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index fa36bf1..c7ed338 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddScoped(sp => sp.GetRequiredService(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/EmailSendAttemptStore.cs b/JobTrackerApi/Services/EmailSendAttemptStore.cs new file mode 100644 index 0000000..a4d115b --- /dev/null +++ b/JobTrackerApi/Services/EmailSendAttemptStore.cs @@ -0,0 +1,124 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public sealed record CreateEmailSendAttempt(int JobApplicationId, string Provider, string ClientRequestId, string PayloadHash); +public sealed record EmailSendAttemptCreation(EmailSendAttempt Attempt, bool Created); + +public sealed class EmailSendConflictException(string message) : InvalidOperationException(message); + +public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider timeProvider) +{ + private DateTime UtcNow => timeProvider.GetUtcNow().UtcDateTime; + + public Task GetAsync(Guid id, CancellationToken cancellationToken) + { + EnsureOwnerScope(); + return db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + } + + public async Task CreateAsync(CreateEmailSendAttempt request, CancellationToken cancellationToken) + { + var ownerUserId = db.CurrentUserId ?? throw new InvalidOperationException("Email send creation requires an authenticated owner scope."); + Validate(request); + if (!await db.JobApplications.AnyAsync(job => job.Id == request.JobApplicationId, cancellationToken)) + throw new InvalidOperationException("The job application does not exist in the current owner scope."); + + var existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); + if (existing is not null) return Existing(existing, request.PayloadHash); + + var attempt = new EmailSendAttempt + { + Id = Guid.NewGuid(), + OwnerUserId = ownerUserId, + JobApplicationId = request.JobApplicationId, + Provider = request.Provider.Trim().ToLowerInvariant(), + ClientRequestId = request.ClientRequestId.Trim(), + PayloadHash = request.PayloadHash.Trim().ToLowerInvariant(), + CreatedAtUtc = UtcNow, + }; + db.EmailSendAttempts.Add(attempt); + try + { + await db.SaveChangesAsync(cancellationToken); + return new EmailSendAttemptCreation(attempt, true); + } + catch (DbUpdateException) + { + db.Entry(attempt).State = EntityState.Detached; + existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); + if (existing is not null) return Existing(existing, request.PayloadHash); + throw; + } + } + + public Task BeginAsync(Guid id, CancellationToken cancellationToken) + { + EnsureOwnerScope(); + var now = UtcNow; + return db.EmailSendAttempts + .Where(item => item.Id == id && item.Status == EmailSendStatuses.Pending) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.Status, EmailSendStatuses.Sending) + .SetProperty(item => item.StartedAtUtc, now), cancellationToken); + } + + public Task MarkSentAsync(Guid id, string? providerMessageId, CancellationToken cancellationToken) => + CompleteAsync(id, EmailSendStatuses.Sent, providerMessageId, null, cancellationToken); + + public Task MarkFailedAsync(Guid id, string failureCategory, CancellationToken cancellationToken) => + CompleteAsync(id, EmailSendStatuses.Failed, null, failureCategory, cancellationToken); + + public Task MarkUncertainAsync(Guid id, string failureCategory, CancellationToken cancellationToken) => + CompleteAsync(id, EmailSendStatuses.Uncertain, null, failureCategory, cancellationToken); + + private Task CompleteAsync(Guid id, string status, string? providerMessageId, string? failureCategory, CancellationToken cancellationToken) + { + EnsureOwnerScope(); + ValidateOptional(providerMessageId, 256, nameof(providerMessageId)); + ValidateOptional(failureCategory, 64, nameof(failureCategory)); + var now = UtcNow; + return db.EmailSendAttempts + .Where(item => item.Id == id && item.Status == EmailSendStatuses.Sending) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.Status, status) + .SetProperty(item => item.ProviderMessageId, providerMessageId) + .SetProperty(item => item.FailureCategory, failureCategory) + .SetProperty(item => item.CompletedAtUtc, now), cancellationToken); + } + + private static EmailSendAttemptCreation Existing(EmailSendAttempt existing, string payloadHash) + { + if (!string.Equals(existing.PayloadHash, payloadHash.Trim(), StringComparison.OrdinalIgnoreCase)) + throw new EmailSendConflictException("The client request ID was already used for different email content."); + return new EmailSendAttemptCreation(existing, false); + } + + private static void Validate(CreateEmailSendAttempt request) + { + if (request.JobApplicationId <= 0) throw new ArgumentOutOfRangeException(nameof(request.JobApplicationId)); + ValidateRequired(request.Provider, 32, nameof(request.Provider)); + ValidateRequired(request.ClientRequestId, 128, nameof(request.ClientRequestId)); + ValidateRequired(request.PayloadHash, 64, nameof(request.PayloadHash)); + if (!Guid.TryParse(request.ClientRequestId, out _)) throw new ArgumentException("Client request ID must be a UUID.", nameof(request.ClientRequestId)); + if (request.PayloadHash.Length != 64 || request.PayloadHash.Any(value => !Uri.IsHexDigit(value))) + throw new ArgumentException("Payload hash must be a SHA-256 hex digest.", nameof(request.PayloadHash)); + } + + private static void ValidateRequired(string value, int maxLength, string name) + { + if (string.IsNullOrWhiteSpace(value) || value.Trim().Length > maxLength) throw new ArgumentException($"{name} is required and must be at most {maxLength} characters.", name); + } + + private static void ValidateOptional(string? value, int maxLength, string name) + { + if (value?.Length > maxLength) throw new ArgumentException($"{name} must be at most {maxLength} characters.", name); + } + + private void EnsureOwnerScope() + { + if (string.IsNullOrWhiteSpace(db.CurrentUserId)) throw new InvalidOperationException("Email send access requires an authenticated owner scope."); + } +}