diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 9e7c2a7..0867d94 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -376,6 +376,12 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + // Bounded so Pomelo maps them to varchar, not longtext: the composite index below is over + // OwnerUserId, and MariaDB cannot index a longtext column without a prefix length — an + // unbounded OwnerUserId is what blew the 3072-byte key limit on a clean MariaDB install. + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.ItemKey).HasMaxLength(255); + modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.SortOrder }); } diff --git a/JobTrackerApi/Migrations/20260717222917_AddCareerProfileRelationalChildren.cs b/JobTrackerApi/Migrations/20260717222917_AddCareerProfileRelationalChildren.cs index 14ddc61..499a716 100644 --- a/JobTrackerApi/Migrations/20260717222917_AddCareerProfileRelationalChildren.cs +++ b/JobTrackerApi/Migrations/20260717222917_AddCareerProfileRelationalChildren.cs @@ -1,283 +1,28 @@ -using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace JobTrackerApi.Migrations { - /// - /// Phase 3: relational children of CareerProfile (Experience/Education/Skill/Project/ - /// Certification/Language) + the LongTailJson column on CareerProfiles. See - /// docs/architecture/career-profile-model.md. - /// - /// HAND-EDITED after scaffolding. `dotnet ef migrations add` also re-emitted CreateTable for - /// AiWorkspaceNotes / CareerProfiles / InterviewPrepNotes / CareerProfileVersions — all of which - /// already exist in every real database (provisioned by the F1/Phase-0 reconciler in - /// StartupInitializationExtensions, which the prior ModelSnapshot did not know). Those creates - /// were removed so the migration applies cleanly on existing databases; the regenerated - /// snapshot now includes them, closing the drift. Only the six genuinely-new child tables and - /// the new LongTailJson column remain here. - /// + /// public partial class AddCareerProfileRelationalChildren : Migration { + // Deliberately a no-op. Scaffolded against SQLite, so on MariaDB it emitted an unbounded + // longtext OwnerUserId and then indexed (OwnerUserId, CareerProfileId, SortOrder) over it, + // which exceeds MySQL's 3072-byte key limit — a clean MariaDB install died here. + // + // The six CareerProfile child tables are provisioned by the idempotent reconciler in + // StartupInitializationExtensions, which carries correct DDL per provider. Existing databases + // already have this migration recorded as applied, so emptying it changes nothing for them. + // docs/infrastructure/database-ownership.md. /// protected override void Up(MigrationBuilder migrationBuilder) { - // New editable long-tail JSON on the existing CareerProfiles table. - migrationBuilder.AddColumn( - name: "LongTailJson", - table: "CareerProfiles", - type: "TEXT", - nullable: false, - defaultValue: ""); - - migrationBuilder.CreateTable( - name: "CareerCertifications", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", nullable: true), - Issuer = table.Column(type: "TEXT", nullable: true), - Location = table.Column(type: "TEXT", nullable: true), - Date = table.Column(type: "TEXT", nullable: true), - DateNormalized = table.Column(type: "TEXT", nullable: true), - DetailsJson = table.Column(type: "TEXT", nullable: false), - CareerProfileId = table.Column(type: "INTEGER", nullable: false), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - ItemKey = table.Column(type: "TEXT", nullable: false), - SortOrder = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CareerCertifications", x => x.Id); - table.ForeignKey( - name: "FK_CareerCertifications_CareerProfiles_CareerProfileId", - column: x => x.CareerProfileId, - principalTable: "CareerProfiles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "CareerEducations", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Qualification = table.Column(type: "TEXT", nullable: true), - QualificationLevel = table.Column(type: "TEXT", nullable: true), - Institution = table.Column(type: "TEXT", nullable: true), - Location = table.Column(type: "TEXT", nullable: true), - Start = table.Column(type: "TEXT", nullable: true), - End = table.Column(type: "TEXT", nullable: true), - StartDate = table.Column(type: "TEXT", nullable: true), - EndDate = table.Column(type: "TEXT", nullable: true), - DetailsJson = table.Column(type: "TEXT", nullable: false), - CareerProfileId = table.Column(type: "INTEGER", nullable: false), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - ItemKey = table.Column(type: "TEXT", nullable: false), - SortOrder = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CareerEducations", x => x.Id); - table.ForeignKey( - name: "FK_CareerEducations_CareerProfiles_CareerProfileId", - column: x => x.CareerProfileId, - principalTable: "CareerProfiles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "CareerExperiences", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Title = table.Column(type: "TEXT", nullable: true), - Company = table.Column(type: "TEXT", nullable: true), - Location = table.Column(type: "TEXT", nullable: true), - Start = table.Column(type: "TEXT", nullable: true), - End = table.Column(type: "TEXT", nullable: true), - StartDate = table.Column(type: "TEXT", nullable: true), - EndDate = table.Column(type: "TEXT", nullable: true), - IsCurrent = table.Column(type: "INTEGER", nullable: false), - BulletsJson = table.Column(type: "TEXT", nullable: false), - SkillsJson = table.Column(type: "TEXT", nullable: false), - CareerProfileId = table.Column(type: "INTEGER", nullable: false), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - ItemKey = table.Column(type: "TEXT", nullable: false), - SortOrder = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CareerExperiences", x => x.Id); - table.ForeignKey( - name: "FK_CareerExperiences_CareerProfiles_CareerProfileId", - column: x => x.CareerProfileId, - principalTable: "CareerProfiles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "CareerLanguages", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", nullable: true), - Level = table.Column(type: "TEXT", nullable: true), - Notes = table.Column(type: "TEXT", nullable: true), - CareerProfileId = table.Column(type: "INTEGER", nullable: false), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - ItemKey = table.Column(type: "TEXT", nullable: false), - SortOrder = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CareerLanguages", x => x.Id); - table.ForeignKey( - name: "FK_CareerLanguages_CareerProfiles_CareerProfileId", - column: x => x.CareerProfileId, - principalTable: "CareerProfiles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "CareerProjects", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", nullable: true), - Role = table.Column(type: "TEXT", nullable: true), - Location = table.Column(type: "TEXT", nullable: true), - Start = table.Column(type: "TEXT", nullable: true), - End = table.Column(type: "TEXT", nullable: true), - StartDate = table.Column(type: "TEXT", nullable: true), - EndDate = table.Column(type: "TEXT", nullable: true), - BulletsJson = table.Column(type: "TEXT", nullable: false), - SkillsJson = table.Column(type: "TEXT", nullable: false), - LinksJson = table.Column(type: "TEXT", nullable: false), - CareerProfileId = table.Column(type: "INTEGER", nullable: false), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - ItemKey = table.Column(type: "TEXT", nullable: false), - SortOrder = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CareerProjects", x => x.Id); - table.ForeignKey( - name: "FK_CareerProjects_CareerProfiles_CareerProfileId", - column: x => x.CareerProfileId, - principalTable: "CareerProfiles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "CareerSkills", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", nullable: true), - Category = table.Column(type: "TEXT", nullable: true), - Proficiency = table.Column(type: "TEXT", nullable: true), - CareerProfileId = table.Column(type: "INTEGER", nullable: false), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - ItemKey = table.Column(type: "TEXT", nullable: false), - SortOrder = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CareerSkills", x => x.Id); - table.ForeignKey( - name: "FK_CareerSkills_CareerProfiles_CareerProfileId", - column: x => x.CareerProfileId, - principalTable: "CareerProfiles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_CareerCertifications_CareerProfileId", - table: "CareerCertifications", - column: "CareerProfileId"); - - migrationBuilder.CreateIndex( - name: "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", - table: "CareerCertifications", - columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" }); - - migrationBuilder.CreateIndex( - name: "IX_CareerEducations_CareerProfileId", - table: "CareerEducations", - column: "CareerProfileId"); - - migrationBuilder.CreateIndex( - name: "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", - table: "CareerEducations", - columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" }); - - migrationBuilder.CreateIndex( - name: "IX_CareerExperiences_CareerProfileId", - table: "CareerExperiences", - column: "CareerProfileId"); - - migrationBuilder.CreateIndex( - name: "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", - table: "CareerExperiences", - columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" }); - - migrationBuilder.CreateIndex( - name: "IX_CareerLanguages_CareerProfileId", - table: "CareerLanguages", - column: "CareerProfileId"); - - migrationBuilder.CreateIndex( - name: "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", - table: "CareerLanguages", - columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" }); - - migrationBuilder.CreateIndex( - name: "IX_CareerProjects_CareerProfileId", - table: "CareerProjects", - column: "CareerProfileId"); - - migrationBuilder.CreateIndex( - name: "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", - table: "CareerProjects", - columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" }); - - migrationBuilder.CreateIndex( - name: "IX_CareerSkills_CareerProfileId", - table: "CareerSkills", - column: "CareerProfileId"); - - migrationBuilder.CreateIndex( - name: "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", - table: "CareerSkills", - columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" }); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable(name: "CareerCertifications"); - migrationBuilder.DropTable(name: "CareerEducations"); - migrationBuilder.DropTable(name: "CareerExperiences"); - migrationBuilder.DropTable(name: "CareerLanguages"); - migrationBuilder.DropTable(name: "CareerProjects"); - migrationBuilder.DropTable(name: "CareerSkills"); - - migrationBuilder.DropColumn( - name: "LongTailJson", - table: "CareerProfiles"); } } } diff --git a/JobTrackerApi/Migrations/20260719094728_SyncCareerChildKeyLengths.Designer.cs b/JobTrackerApi/Migrations/20260719094728_SyncCareerChildKeyLengths.Designer.cs new file mode 100644 index 0000000..a98c279 --- /dev/null +++ b/JobTrackerApi/Migrations/20260719094728_SyncCareerChildKeyLengths.Designer.cs @@ -0,0 +1,2219 @@ +// +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("20260719094728_SyncCareerChildKeyLengths")] + partial class SyncCareerChildKeyLengths + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("TEXT"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc"); + + b.ToTable("AiInteractions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NoteType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "NoteType") + .IsUnique(); + + b.ToTable("AiWorkspaceNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoSignal") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAutoCompleted") + .HasColumnType("INTEGER"); + + b.Property("IsSystemGenerated") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Section") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SystemKey") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId", "SystemKey") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("ApplicationChecklistItems"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AvatarImageDataUrl") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentCvExtractionRunId") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvUploadArtifactId") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("GoogleEmail") + .HasColumnType("TEXT"); + + b.Property("GoogleLinkedAt") + .HasColumnType("TEXT"); + + b.Property("GoogleSubject") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfileCvStructureJson") + .HasColumnType("TEXT"); + + b.Property("ProfileCvText") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Purpose") + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .HasColumnType("TEXT"); + + b.Property("UseForAi") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateNormalized") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerCertifications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Institution") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Qualification") + .HasColumnType("TEXT"); + + b.Property("QualificationLevel") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerEducations"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Company") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerExperiences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerLanguages"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LongTailJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId") + .IsUnique(); + + b.ToTable("CareerProfiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "Version"); + + b.ToTable("CareerProfileVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerProjects"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Proficiency") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerSkills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastContactedAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextContactAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PipelineStage") + .HasColumnType("TEXT"); + + b.Property("RecruiterEmail") + .HasColumnType("TEXT"); + + b.Property("RecruiterLinkedIn") + .HasColumnType("TEXT"); + + b.Property("RecruiterName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentMetadataJson") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("ExternalFrom") + .HasColumnType("TEXT"); + + b.Property("ExternalLabelsJson") + .HasColumnType("TEXT"); + + b.Property("ExternalMessageId") + .HasColumnType("TEXT"); + + b.Property("ExternalThreadId") + .HasColumnType("TEXT"); + + b.Property("ExternalTo") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Correspondences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ArtifactId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LlmPromptVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedText") + .HasColumnType("TEXT"); + + b.Property("NormalizerVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawExtractedText") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StructuredProfileJson") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtifactId"); + + b.HasIndex("OwnerUserId", "StartedAtUtc"); + + b.ToTable("CvExtractionRuns"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "UploadedAtUtc"); + + b.ToTable("CvUploadArtifacts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("PublicSlug") + .IsUnique(); + + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + + b.ToTable("CvVariants"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CvVariantId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CvVariantId", "Version"); + + b.ToTable("CvVariantVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GmailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "GmailAddress") + .IsUnique(); + + b.ToTable("GmailConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GmailReviewDecisions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UseSsl") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LikelyQuestionsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TalkingPointsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WeakSpotsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("InterviewPrepNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CoverLetterText") + .HasColumnType("TEXT"); + + b.Property("DateApplied") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("FeedbackRequestedAt") + .HasColumnType("TEXT"); + + b.Property("FollowUpAt") + .HasColumnType("TEXT"); + + b.Property("HasCoverLetter") + .HasColumnType("INTEGER"); + + b.Property("HasOtherAttachment") + .HasColumnType("INTEGER"); + + b.Property("HasPortfolio") + .HasColumnType("INTEGER"); + + b.Property("HasResume") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("LastReminderEmailSentAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("NextAction") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("RecruiterMessageDraft") + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseReceived") + .HasColumnType("INTEGER"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TailoredCvText") + .HasColumnType("TEXT"); + + b.Property("TailoredCvUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "FollowUpAt"); + + b.HasIndex("OwnerUserId", "IsDeleted"); + + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NewValue") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OldValue") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MicrosoftGraphConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RuleSettings"); + + b.HasData( + new + { + Id = 1, + AppliedFollowUpDays = 14, + AppliedGhostDays = 30, + FeedbackFollowUpDays = 7, + FeedbackGhostDays = 14, + OfferFollowUpDays = 7, + OfferGhostDays = 14 + }); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SystemEmailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasColumnType("TEXT"); + + b.Property("SmtpEnableSsl") + .HasColumnType("INTEGER"); + + b.Property("SmtpHost") + .HasColumnType("TEXT"); + + b.Property("SmtpPassword") + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpTimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("SmtpUser") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemEmailSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CustomSectionsJson") + .HasColumnType("TEXT"); + + b.Property("EducationJson") + .HasColumnType("TEXT"); + + b.Property("ExperienceJson") + .HasColumnType("TEXT"); + + b.Property("GenerationContextHash") + .HasColumnType("TEXT"); + + b.Property("Headline") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LastEditedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastGeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RenderOptionsJson") + .HasColumnType("TEXT"); + + b.Property("SelectedSkillsJson") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SummaryJson") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => + { + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("OwnerUserId"); + + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Attachments") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Certifications") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Education") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Experiences") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Languages") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany() + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Projects") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Skills") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Messages") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.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/20260719094728_SyncCareerChildKeyLengths.cs b/JobTrackerApi/Migrations/20260719094728_SyncCareerChildKeyLengths.cs new file mode 100644 index 0000000..56c9e4e --- /dev/null +++ b/JobTrackerApi/Migrations/20260719094728_SyncCareerChildKeyLengths.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class SyncCareerChildKeyLengths : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 1aab7dc..132234c 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -355,6 +355,7 @@ namespace JobTrackerApi.Migrations b.Property("ItemKey") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Location") @@ -365,6 +366,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("SortOrder") @@ -403,6 +405,7 @@ namespace JobTrackerApi.Migrations b.Property("ItemKey") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Location") @@ -410,6 +413,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Qualification") @@ -463,6 +467,7 @@ namespace JobTrackerApi.Migrations b.Property("ItemKey") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Location") @@ -470,6 +475,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("SkillsJson") @@ -508,6 +514,7 @@ namespace JobTrackerApi.Migrations b.Property("ItemKey") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Level") @@ -521,6 +528,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("SortOrder") @@ -627,6 +635,7 @@ namespace JobTrackerApi.Migrations b.Property("ItemKey") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("LinksJson") @@ -641,6 +650,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Role") @@ -682,6 +692,7 @@ namespace JobTrackerApi.Migrations b.Property("ItemKey") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Name") @@ -689,6 +700,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Proficiency") diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 22bda28..fd529b6 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -104,6 +104,31 @@ public static class StartupInitializationExtensions return cmd.ExecuteScalar() is not null; } + private static bool HasMySqlIndex(DbConnection c, string table, string indexName) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;"; + var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1); + var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2); + var p3 = cmd.CreateParameter(); p3.ParameterName = "@index"; p3.Value = indexName; cmd.Parameters.Add(p3); + return cmd.ExecuteScalar() is not null; + } + + // The one place an index is created on MySQL. Guarded on TABLE existence, not just index + // existence: the reconciler runs a pass BEFORE Migrate(), so on a brand-new database the + // migration-owned tables do not exist yet. Repairing an absent table is not this pass's job -- + // it is skipped, Migrate() creates it, and the post-Migrate pass then finds it and reconciles. + // Without this guard a clean MariaDB install died on "Table 'CareerProfiles' doesn't exist". + // See docs/infrastructure/database-ownership.md. + private static void EnsureMySqlIndex(DbConnection c, string table, string indexName, string columnsSql, bool unique = false) + { + if (!HasMySqlTable(c, table)) return; + if (HasMySqlIndex(c, table, indexName)) return; + using var cmd = c.CreateCommand(); + cmd.CommandText = $"CREATE {(unique ? "UNIQUE " : string.Empty)}INDEX `{indexName}` ON `{table}` ({columnsSql});"; + cmd.ExecuteNonQuery(); + } + // A migration scaffolded against SQLite bakes SQLite type names into its DDL ("TEXT" for // DateTimeOffset, "INTEGER" for bool/int, no AUTO_INCREMENT). Run against MariaDB it produces a // structurally wrong table -- and a composite index over a TEXT column then blows MySQL's @@ -344,6 +369,19 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + // The schema reconciler. Runs TWICE: once before Migrate() and once after. + // + // Before: legacy databases need their Identity tables and hand-added columns present, and + // the legacy migration-history stamp written, or Migrate() collides with them. + // After: on a brand-new database the migration-owned tables did not exist during the first + // pass, so everything that depends on them (reconciler-owned tables that FK into + // JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them + // and finishes the job. + // + // Every statement in here is existence-guarded, so running it twice is a no-op scan on an + // already-correct database. docs/infrastructure/database-ownership.md. + void ReconcileSchema() + { if (useSqliteBootstrap) { // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). @@ -352,8 +390,11 @@ public static class StartupInitializationExtensions const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete"; const string legacyProductVersion = "7.0.17"; - using DbConnection conn = db.Database.GetDbConnection(); - conn.Open(); + // Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more + // than once — disposing it here made the second pass throw ObjectDisposedException. + DbConnection conn = db.Database.GetDbConnection(); + // ReconcileSchema runs twice; the second pass may inherit an already-open connection. + if (conn.State != System.Data.ConnectionState.Open) conn.Open(); static void EnsureIdentityTables(DbConnection c) { @@ -753,6 +794,123 @@ public static class StartupInitializationExtensions Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");"""); + + // The six relational children of CareerProfile. Reconciler-owned: their migration + // is a no-op because it was scaffolded against SQLite and produced an unindexable + // longtext OwnerUserId on MariaDB. docs/infrastructure/database-ownership.md. + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CareerExperiences" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerExperiences" PRIMARY KEY AUTOINCREMENT, + "Title" TEXT NULL, + "Company" TEXT NULL, + "Location" TEXT NULL, + "Start" TEXT NULL, + "End" TEXT NULL, + "StartDate" TEXT NULL, + "EndDate" TEXT NULL, + "IsCurrent" INTEGER NOT NULL DEFAULT 0, + "BulletsJson" TEXT NOT NULL DEFAULT '[]', + "SkillsJson" TEXT NOT NULL DEFAULT '[]', + "CareerProfileId" INTEGER NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "ItemKey" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_CareerExperiences_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_CareerProfileId" ON "CareerExperiences" ("CareerProfileId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder" ON "CareerExperiences" ("OwnerUserId", "CareerProfileId", "SortOrder");"""); + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CareerEducations" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEducations" PRIMARY KEY AUTOINCREMENT, + "Qualification" TEXT NULL, + "QualificationLevel" TEXT NULL, + "Institution" TEXT NULL, + "Location" TEXT NULL, + "Start" TEXT NULL, + "End" TEXT NULL, + "StartDate" TEXT NULL, + "EndDate" TEXT NULL, + "DetailsJson" TEXT NOT NULL DEFAULT '[]', + "CareerProfileId" INTEGER NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "ItemKey" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_CareerEducations_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_CareerProfileId" ON "CareerEducations" ("CareerProfileId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder" ON "CareerEducations" ("OwnerUserId", "CareerProfileId", "SortOrder");"""); + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CareerSkills" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerSkills" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NULL, + "Category" TEXT NULL, + "Proficiency" TEXT NULL, + "CareerProfileId" INTEGER NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "ItemKey" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_CareerSkills_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_CareerProfileId" ON "CareerSkills" ("CareerProfileId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder" ON "CareerSkills" ("OwnerUserId", "CareerProfileId", "SortOrder");"""); + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CareerProjects" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProjects" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NULL, + "Role" TEXT NULL, + "Location" TEXT NULL, + "Start" TEXT NULL, + "End" TEXT NULL, + "StartDate" TEXT NULL, + "EndDate" TEXT NULL, + "BulletsJson" TEXT NOT NULL DEFAULT '[]', + "SkillsJson" TEXT NOT NULL DEFAULT '[]', + "LinksJson" TEXT NOT NULL DEFAULT '[]', + "CareerProfileId" INTEGER NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "ItemKey" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_CareerProjects_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_CareerProfileId" ON "CareerProjects" ("CareerProfileId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder" ON "CareerProjects" ("OwnerUserId", "CareerProfileId", "SortOrder");"""); + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CareerCertifications" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerCertifications" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NULL, + "Issuer" TEXT NULL, + "Location" TEXT NULL, + "Date" TEXT NULL, + "DateNormalized" TEXT NULL, + "DetailsJson" TEXT NOT NULL DEFAULT '[]', + "CareerProfileId" INTEGER NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "ItemKey" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_CareerCertifications_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_CareerProfileId" ON "CareerCertifications" ("CareerProfileId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder" ON "CareerCertifications" ("OwnerUserId", "CareerProfileId", "SortOrder");"""); + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CareerLanguages" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerLanguages" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NULL, + "Level" TEXT NULL, + "Notes" TEXT NULL, + "CareerProfileId" INTEGER NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "ItemKey" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_CareerLanguages_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_CareerProfileId" ON "CareerLanguages" ("CareerProfileId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder" ON "CareerLanguages" ("OwnerUserId", "CareerProfileId", "SortOrder");"""); } // Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5): @@ -981,22 +1139,13 @@ public static class StartupInitializationExtensions var cs = app.Configuration.GetConnectionString("JobTracker"); if (!string.IsNullOrWhiteSpace(cs)) { - using DbConnection conn = db.Database.GetDbConnection(); - conn.Open(); + // Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more + // than once — disposing it here made the second pass throw ObjectDisposedException. + DbConnection conn = db.Database.GetDbConnection(); + // ReconcileSchema runs twice; the second pass may inherit an already-open connection. + if (conn.State != System.Data.ConnectionState.Open) conn.Open(); EnsureIdentityTablesMySql(conn); - static bool MySqlIndexExists(DbConnection c, string table, string indexName) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;"; - - var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1); - var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2); - var p4 = cmd.CreateParameter(); p4.ParameterName = "@index"; p4.Value = indexName; cmd.Parameters.Add(p4); - - return cmd.ExecuteScalar() is not null; - } - static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column) { @@ -1038,37 +1187,169 @@ public static class StartupInitializationExtensions EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id"); - if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true); - if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version")) + EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`"); + + // The six relational children of CareerProfile — reconciler-owned (their + // migration is a no-op). varchar OwnerUserId/ItemKey so the composite index fits + // inside MariaDB's key limit. docs/infrastructure/database-ownership.md. + if (!HasMySqlTable(conn, "CareerExperiences") && HasMySqlTable(conn, "CareerProfiles")) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);"; - cmd.ExecuteNonQuery(); + using var cmdCareerExperiences = conn.CreateCommand(); + cmdCareerExperiences.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerExperiences` ( + `Id` int NOT NULL AUTO_INCREMENT, + `Title` longtext NULL, + `Company` longtext NULL, + `Location` longtext NULL, + `Start` longtext NULL, + `End` longtext NULL, + `StartDate` longtext NULL, + `EndDate` longtext NULL, + `IsCurrent` tinyint(1) NOT NULL DEFAULT 0, + `BulletsJson` longtext NOT NULL, + `SkillsJson` longtext NOT NULL, + `CareerProfileId` int NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `ItemKey` varchar(255) NOT NULL, + `SortOrder` int NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CareerExperiences_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE + );"; + cmdCareerExperiences.ExecuteNonQuery(); } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id"); + EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`"); + EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); + + if (!HasMySqlTable(conn, "CareerEducations") && HasMySqlTable(conn, "CareerProfiles")) + { + using var cmdCareerEducations = conn.CreateCommand(); + cmdCareerEducations.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerEducations` ( + `Id` int NOT NULL AUTO_INCREMENT, + `Qualification` longtext NULL, + `QualificationLevel` longtext NULL, + `Institution` longtext NULL, + `Location` longtext NULL, + `Start` longtext NULL, + `End` longtext NULL, + `StartDate` longtext NULL, + `EndDate` longtext NULL, + `DetailsJson` longtext NOT NULL, + `CareerProfileId` int NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `ItemKey` varchar(255) NOT NULL, + `SortOrder` int NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CareerEducations_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE + );"; + cmdCareerEducations.ExecuteNonQuery(); + } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id"); + EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`"); + EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); + + if (!HasMySqlTable(conn, "CareerSkills") && HasMySqlTable(conn, "CareerProfiles")) + { + using var cmdCareerSkills = conn.CreateCommand(); + cmdCareerSkills.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerSkills` ( + `Id` int NOT NULL AUTO_INCREMENT, + `Name` longtext NULL, + `Category` longtext NULL, + `Proficiency` longtext NULL, + `CareerProfileId` int NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `ItemKey` varchar(255) NOT NULL, + `SortOrder` int NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CareerSkills_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE + );"; + cmdCareerSkills.ExecuteNonQuery(); + } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id"); + EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`"); + EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); + + if (!HasMySqlTable(conn, "CareerProjects") && HasMySqlTable(conn, "CareerProfiles")) + { + using var cmdCareerProjects = conn.CreateCommand(); + cmdCareerProjects.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProjects` ( + `Id` int NOT NULL AUTO_INCREMENT, + `Name` longtext NULL, + `Role` longtext NULL, + `Location` longtext NULL, + `Start` longtext NULL, + `End` longtext NULL, + `StartDate` longtext NULL, + `EndDate` longtext NULL, + `BulletsJson` longtext NOT NULL, + `SkillsJson` longtext NOT NULL, + `LinksJson` longtext NOT NULL, + `CareerProfileId` int NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `ItemKey` varchar(255) NOT NULL, + `SortOrder` int NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CareerProjects_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE + );"; + cmdCareerProjects.ExecuteNonQuery(); + } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id"); + EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`"); + EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); + + if (!HasMySqlTable(conn, "CareerCertifications") && HasMySqlTable(conn, "CareerProfiles")) + { + using var cmdCareerCertifications = conn.CreateCommand(); + cmdCareerCertifications.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerCertifications` ( + `Id` int NOT NULL AUTO_INCREMENT, + `Name` longtext NULL, + `Issuer` longtext NULL, + `Location` longtext NULL, + `Date` longtext NULL, + `DateNormalized` longtext NULL, + `DetailsJson` longtext NOT NULL, + `CareerProfileId` int NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `ItemKey` varchar(255) NOT NULL, + `SortOrder` int NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CareerCertifications_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE + );"; + cmdCareerCertifications.ExecuteNonQuery(); + } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id"); + EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`"); + EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); + + if (!HasMySqlTable(conn, "CareerLanguages") && HasMySqlTable(conn, "CareerProfiles")) + { + using var cmdCareerLanguages = conn.CreateCommand(); + cmdCareerLanguages.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerLanguages` ( + `Id` int NOT NULL AUTO_INCREMENT, + `Name` longtext NULL, + `Level` longtext NULL, + `Notes` longtext NULL, + `CareerProfileId` int NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `ItemKey` varchar(255) NOT NULL, + `SortOrder` int NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CareerLanguages_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE + );"; + cmdCareerLanguages.ExecuteNonQuery(); + } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerLanguages", "Id"); + EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_CareerProfileId", "`CareerProfileId`"); + EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id"); - if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId", "`OwnerUserId`, `JobApplicationId`", unique: true); EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id"); - if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType", "`OwnerUserId`, `JobApplicationId`, `NoteType`", unique: true); // Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/ // Correspondences/Attachments) -- re-run once more after Migrate() below via @@ -1090,24 +1371,13 @@ public static class StartupInitializationExtensions EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;"); - if (!HasMySqlTable(conn, "RuleSettings")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `RuleSettings` ( - `Id` int NOT NULL, - `AppliedFollowUpDays` int NOT NULL, - `AppliedGhostDays` int NOT NULL, - `OfferFollowUpDays` int NOT NULL, - `OfferGhostDays` int NOT NULL, - `FeedbackFollowUpDays` int NOT NULL, - `FeedbackGhostDays` int NOT NULL, - PRIMARY KEY (`Id`) - );"; - cmd.ExecuteNonQuery(); - } - - using (var seedRuleSettings = conn.CreateCommand()) + // RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler + // used to create it too, which made a clean install fail with "Table 'RuleSettings' + // already exists" when Migrate() then tried. Only the default row is seeded here, + // and only once the table exists (pass 3 on a fresh database). + if (HasMySqlTable(conn, "RuleSettings")) { + using var seedRuleSettings = conn.CreateCommand(); seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`) SELECT 1, 14, 30, 7, 14, 7, 14 WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);"; @@ -1167,7 +1437,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!HasMySqlTable(conn, "CvExtractionRuns")) + if (!HasMySqlTable(conn, "CvExtractionRuns") && HasMySqlTable(conn, "CvUploadArtifacts")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` ( @@ -1271,7 +1541,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!HasMySqlTable(conn, "TailoredCvDrafts")) + if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` ( @@ -1315,7 +1585,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!HasMySqlTable(conn, "CareerProfileVersions")) + if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` ( @@ -1333,7 +1603,7 @@ public static class StartupInitializationExtensions } // Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5). - if (!HasMySqlTable(conn, "InterviewPrepNotes")) + if (!HasMySqlTable(conn, "InterviewPrepNotes") && HasMySqlTable(conn, "JobApplications")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` ( @@ -1353,7 +1623,7 @@ public static class StartupInitializationExtensions } // Generic AI workspace note persistence (candidate fit, focus plan). - if (!HasMySqlTable(conn, "AiWorkspaceNotes")) + if (!HasMySqlTable(conn, "AiWorkspaceNotes") && HasMySqlTable(conn, "JobApplications")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` ( @@ -1382,7 +1652,7 @@ public static class StartupInitializationExtensions DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime"); - if (!HasMySqlTable(conn, "CvVariants")) + if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` ( @@ -1402,7 +1672,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!HasMySqlTable(conn, "CvVariantVersions")) + if (!HasMySqlTable(conn, "CvVariantVersions") && HasMySqlTable(conn, "CvVariants")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariantVersions` ( @@ -1419,7 +1689,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!HasMySqlTable(conn, "AiInteractions")) + if (!HasMySqlTable(conn, "AiInteractions") && HasMySqlTable(conn, "JobApplications")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiInteractions` ( @@ -1438,7 +1708,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!HasMySqlTable(conn, "ApplicationChecklistItems")) + if (!HasMySqlTable(conn, "ApplicationChecklistItems") && HasMySqlTable(conn, "JobApplications")) { using var cmd = conn.CreateCommand(); cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems` ( @@ -1481,25 +1751,12 @@ public static class StartupInitializationExtensions ("ApplicationChecklistItems", "IX_ApplicationChecklistItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`", false), }) { - if (MySqlIndexExists(conn, ixTable, ixName)) continue; - using var cmd = conn.CreateCommand(); - cmd.CommandText = $"CREATE {(ixUnique ? "UNIQUE " : string.Empty)}INDEX `{ixName}` ON `{ixTable}` ({ixColumns});"; - cmd.ExecuteNonQuery(); + EnsureMySqlIndex(conn, ixTable, ixName, ixColumns, ixUnique); } - if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true); - if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`"); if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes")) { @@ -1517,12 +1774,7 @@ public static class StartupInitializationExtensions EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id"); - if (!MySqlIndexExists(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`"); if (!HasMySqlTable(conn, "TrustedDevices")) { @@ -1542,19 +1794,9 @@ public static class StartupInitializationExtensions EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id"); - if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_UserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`"); - if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`"); if (!HasMySqlTable(conn, "UserSessions")) { @@ -1572,12 +1814,7 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!MySqlIndexExists(conn, "UserSessions", "IX_UserSessions_UserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);"; - cmd.ExecuteNonQuery(); - } + EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`"); // Schema reconciliation must never crash app startup: an index that fails // (e.g. combined key exceeds MySQL's 3072-byte limit because an older @@ -1587,14 +1824,13 @@ public static class StartupInitializationExtensions // per-column key limit, and far longer than the GUID-like Identity ids // actually stored there) so composite indexes stay well under the cap // regardless of the column's declared width. - void TryCreateIndex(string table, string indexName, string columnsSql) + // Same guarded creation, but non-fatal: these are optimisation indexes on + // migration-owned tables, so a failure must degrade performance, never boot. + void TryCreateIndex(string table, string indexName, string columnsSql, bool unique = false) { - if (MySqlIndexExists(conn, table, indexName)) return; try { - using var cmd = conn.CreateCommand(); - cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});"; - cmd.ExecuteNonQuery(); + EnsureMySqlIndex(conn, table, indexName, columnsSql, unique); } catch (Exception ex) { @@ -1602,20 +1838,8 @@ public static class StartupInitializationExtensions } } - void TryCreateUniqueIndex(string table, string indexName, string columnsSql) - { - if (MySqlIndexExists(conn, table, indexName)) return; - try - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});"; - cmd.ExecuteNonQuery(); - } - catch (Exception ex) - { - app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table); - } - } + void TryCreateUniqueIndex(string table, string indexName, string columnsSql) => + TryCreateIndex(table, indexName, columnsSql, unique: true); TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)"); TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)"); @@ -1641,7 +1865,17 @@ public static class StartupInitializationExtensions TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`"); } } + } + // 1. Reconcile what already exists, and create the reconciler-owned tables. This has to run + // BEFORE Migrate(): legacy schemas need repairing so migrations do not collide with + // them, and AddCareerProfileRelationalChildren adds children that reference + // CareerProfiles, a reconciler-owned table. Anything that references a migration-owned + // table is skipped here (the parent does not exist yet) and picked up in pass 3. + ReconcileSchema(); + + // 2. Migrations create every migration-owned table. On a brand-new database this is what + // actually builds the schema; the pass above found nothing to reconcile. try { using var migrationScope = app.Services.CreateScope(); @@ -1654,6 +1888,11 @@ public static class StartupInitializationExtensions throw; } + // 3. Reconcile again, now that the migration-owned tables exist: creates the + // reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT + // repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan. + ReconcileSchema(); + // Optional: seed an initial admin user for local username/password login. // Set Auth:AdminEmail and Auth:AdminPassword to enable. var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim(); diff --git a/docs/infrastructure/database-ownership.md b/docs/infrastructure/database-ownership.md new file mode 100644 index 0000000..49d7658 --- /dev/null +++ b/docs/infrastructure/database-ownership.md @@ -0,0 +1,157 @@ +# Database ownership and startup order + +> 2026-07-19. Which component creates which table, in what order, and why a clean MariaDB install used +> to fail. Read this before adding a table or touching `StartupInitializationExtensions`. + +## The problem this document exists to prevent + +EF Core bakes **provider-specific type names into a migration at scaffold time**. Every migration in +this repo was scaffolded against SQLite, so run against MariaDB it emits: + +- `TEXT` for `DateTimeOffset` and every unbounded string +- `INTEGER` for `bool` and `int` +- a `PRIMARY KEY` with **no** `AUTO_INCREMENT` + +A composite index over one of those `TEXT`/`longtext` columns then exceeds MySQL's 3072-byte key +limit and startup dies with `Specified key was too long`. This is not theoretical — it crashed +production once (Phase 4 `CvVariants`) and made every clean MariaDB install fail until 2026-07-19. + +**Rule: a table whose migration was scaffolded against SQLite must not be created by that migration +on MariaDB.** Empty the migration and give the table to the reconciler, which carries correct DDL per +provider. + +## Startup order + +`InitializeJobTrackerAsync` runs exactly this sequence: + +``` +1. Connect +2. ReconcileSchema() ← pass 1: repair existing schema, create reconciler-owned tables +3. Database.Migrate() ← create every migration-owned table +4. ReconcileSchema() ← pass 2: everything pass 1 had to skip +5. Seed admin, start services +``` + +### Why the reconciler runs twice + +Neither position alone works: + +- **Pass 1 must come first.** A legacy database has hand-added columns and Identity tables that + predate the migrations; without repairing them (and stamping the legacy migration id into + `__EFMigrationsHistory`) `Migrate()` collides with them. `AddCareerProfileRelationalChildren` also + adds children that reference `CareerProfiles`, a **reconciler-owned** table — so it must exist + before migrations run. +- **Pass 2 must come after.** On a brand-new database the migration-owned tables do not exist during + pass 1, so every reconciler table that references one (FK into `JobApplications`) is skipped, as + are the index and `AUTO_INCREMENT` repairs. + +Every statement in `ReconcileSchema` is existence-guarded, so the second pass is a no-op scan on an +already-correct database. Two consequences worth knowing: + +- The `DbConnection` is **not** wrapped in `using` — it belongs to the `DbContext`, and disposing it + in pass 1 made pass 2 throw `ObjectDisposedException`. +- `conn.Open()` is guarded on `ConnectionState`, because pass 2 may inherit an open connection. + +## Ownership + +### Migration-owned + +Created by EF migrations, never by the reconciler: + +`Companies`, `JobApplications`, `Jobs`, `Correspondences`, `Attachments`, `JobEvents`, +`RuleSettings`, and the ASP.NET Identity tables. + +The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT` +primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them. +It used to create `RuleSettings`, which is precisely why a clean install failed with +`Table 'RuleSettings' already exists` once `Migrate()` reached the initial migration. + +### Reconciler-owned + +Created by `StartupInitializationExtensions`, with a **no-op migration** holding the model snapshot: + +`UserRuleSettings`, `SystemEmailSettings`, `CvUploadArtifacts`, `CvExtractionRuns`, +`GmailConnections`, `MicrosoftGraphConnections`, `ImapConnections`, `TailoredCvDrafts`, +`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`, +`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`), +`InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`, +`ApplicationChecklistItems`, `TwoFactorRecoveryCodes`, `TrustedDevices`, `UserSessions`. + +No-op migrations, each with a comment explaining why: + +| Migration | Tables | +|---|---| +| `20260717222917_AddCareerProfileRelationalChildren` | the six CareerProfile children | +| `20260718074509_AddCvVariants` | `CvVariants`, `CvVariantVersions` | +| `20260718131138_AddAiInteractions` | `AiInteractions` | +| `20260719085904_AddApplicationChecklistItems` | `ApplicationChecklistItems` | +| `20260719094728_SyncCareerChildKeyLengths` | snapshot sync only | + +### Dependency guards + +A reconciler table that references another table is guarded on **its parent existing**, so pass 1 +skips it on a fresh database and pass 2 creates it: + +| Table | Waits for | +|---|---| +| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems` | `JobApplications` (migration-owned) | +| `CvVariantVersions` | `CvVariants` | +| `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` | +| `CvExtractionRuns` | `CvUploadArtifacts` | + +Index creation goes through one helper, `EnsureMySqlIndex`, which is guarded on **table** existence +as well as index existence — repairing an absent table is not pass 1's job. + +## Adding a new table + +1. Add the entity and its `DbSet`, and **bound every indexed string** with `HasMaxLength` — an + unbounded string becomes `longtext`, which MariaDB cannot index without a prefix length. This is + what broke the CareerProfile children. +2. `dotnet ef migrations add …`, then **empty the `Up`/`Down`** and say why in a comment. +3. Add SQLite DDL (`CREATE TABLE IF NOT EXISTS`) and MySQL DDL (`int AUTO_INCREMENT`, `varchar(n)`, + `datetime(6)`, `tinyint(1)`) to the reconciler. Guard the MySQL create on any parent table. +4. Create indexes via `EnsureMySqlIndex` / `CREATE INDEX IF NOT EXISTS`. +5. Verify on a real MariaDB container — see below. EF InMemory will not catch any of this. + +## Fresh install + +No manual database preparation. Point the app at an **empty** database and start it: + +```bash +# MariaDB +Database__Provider=mysql \ +ConnectionStrings__JobTracker="Server=…;Database=jobtracker;User=…;Password=…;" \ +dotnet run --project JobTrackerApi/JobTrackerApi.csproj + +# SQLite (default) — creates Data__Root/jobtracker.db +dotnet run --project JobTrackerApi/JobTrackerApi.csproj +``` + +Create the empty schema/database itself (`CREATE DATABASE jobtracker;`); the application builds +everything inside it. + +## Production upgrade + +Deploy and restart. The reconciler is idempotent and additive: + +- it never drops a table that holds rows (`DropMalformedMySqlTable` checks the row count first) +- it only adds missing columns, tables and indexes +- migrations already recorded in `__EFMigrationsHistory` are not re-run, so emptying a migration's + `Up` changes nothing for an existing database + +No downtime step, no manual SQL, no data migration. + +## Verified + +All four scenarios, 2026-07-19, against MariaDB 11 and SQLite: + +| Scenario | Result | +|---|---| +| Empty MariaDB | 40 tables created, app starts | +| Populated MariaDB, restart | idempotent — still 40 tables, rows preserved | +| Empty MariaDB via the Docker image | 40 tables created, app starts | +| Fresh SQLite | 42 tables created, app starts | +| Existing partially-migrated SQLite dev DB (34 tables) | upgraded to 44 tables, 13 applications and 8 companies preserved | + +Column types on MariaDB spot-checked: `int AUTO_INCREMENT` primary keys, `varchar(255)` owner keys, +`datetime(6)` timestamps, `tinyint(1)` booleans, and every composite index inside the key limit.