using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Models; namespace JobTrackerApi.Data { public class JobTrackerContext : IdentityDbContext { private readonly JobTrackerApi.Services.ICurrentUserService _currentUser; // Evaluated live on each access, NOT captured in the constructor. The "local" JwtBearer // OnTokenValidated resolves this request-scoped DbContext to run LocalSessionValidator BEFORE // the authentication middleware assigns HttpContext.User. A constructor snapshot therefore froze // CurrentUserId to null for the whole request, and the same scoped instance is reused by the // controller — so every tenant-filtered read compiled to `WHERE FALSE` and returned nothing // (created rows 404'd on read, lists came back empty) even though writes set OwnerUserId // correctly from the controller-resolved user. Reading it live means the query filters see the // authenticated user at query-execution time. Deny-on-null is preserved: it is still null for an // unauthenticated principal. public string? CurrentUserId => _currentUser.UserId; public JobTrackerContext(DbContextOptions options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options) { _currentUser = currentUser; } public DbSet Companies => Set(); public DbSet Jobs => Set(); public DbSet JobApplications => Set(); public DbSet Correspondences => Set(); public DbSet GmailConnections => Set(); public DbSet GmailReviewDecisions => Set(); public DbSet MicrosoftGraphConnections => Set(); public DbSet ImapConnections => Set(); public DbSet Attachments => Set(); public DbSet RuleSettings => Set(); public DbSet UserRuleSettings => Set(); public DbSet SystemEmailSettings => Set(); public DbSet JobEvents => Set(); public DbSet CvUploadArtifacts => Set(); public DbSet CvExtractionRuns => Set(); public DbSet TailoredCvDrafts => Set(); public DbSet TwoFactorRecoveryCodes => Set(); public DbSet TrustedDevices => Set(); public DbSet UserSessions => Set(); public DbSet CareerProfiles => Set(); public DbSet CareerProfileVersions => Set(); public DbSet CareerExperiences => Set(); public DbSet CareerEducations => Set(); public DbSet CareerSkills => Set(); public DbSet CareerProjects => Set(); public DbSet CareerCertifications => Set(); public DbSet CareerLanguages => Set(); public DbSet InterviewPrepNotes => Set(); public DbSet AiWorkspaceNotes => Set(); public DbSet CvVariants => Set(); public DbSet CvVariantVersions => Set(); public DbSet AiInteractions => Set(); public DbSet AiUsageRecords => Set(); public DbSet ApplicationChecklistItems => Set(); public DbSet CoverLetterVersions => Set(); public DbSet InterviewPrepItems => Set(); public DbSet UserOperations => Set(); public DbSet UserNotifications => Set(); public DbSet EmailSendAttempts => Set(); public DbSet EmailDrafts => Set(); public DbSet AccountDeletionRequests => Set(); public DbSet AccountDeletionFiles => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity() .Property(x => x.PendingEmail) .HasMaxLength(320); modelBuilder.Entity() .Property(x => x.MicrosoftTenantId) .HasMaxLength(36); modelBuilder.Entity() .Property(x => x.MicrosoftObjectId) .HasMaxLength(36); modelBuilder.Entity() .Property(x => x.DeletionStatus) .HasMaxLength(32) .HasDefaultValue(AccountDeletionStatuses.Active); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.OwnerKey).HasMaxLength(64); modelBuilder.Entity().Property(x => x.RequestedByUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); modelBuilder.Entity().Property(x => x.Stage).HasMaxLength(32); modelBuilder.Entity().Property(x => x.LastErrorCategory).HasMaxLength(64); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.Status }); modelBuilder.Entity() .HasIndex(x => new { x.Status, x.RequestedAtUtc }); modelBuilder.Entity().Property(x => x.Category).HasMaxLength(64); modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); modelBuilder.Entity().Property(x => x.Sha256).HasMaxLength(64); modelBuilder.Entity() .HasIndex(x => new { x.AccountDeletionRequestId, x.Status }); modelBuilder.Entity() .HasOne(x => x.Request) .WithMany(x => x.Files) .HasForeignKey(x => x.AccountDeletionRequestId) .OnDelete(DeleteBehavior.Cascade); // Both supported databases allow multiple NULL values in a unique index, so legacy // rows remain unassigned while each proven tenant/object pair has exactly one owner. modelBuilder.Entity() .HasIndex(x => new { x.MicrosoftTenantId, x.MicrosoftObjectId }) .IsUnique(); modelBuilder.Entity() .HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId); // Job (the opportunity) is tenant-owned like everything else: same deny-on-null filter, // so a null CurrentUserId returns nothing rather than every tenant's rows. modelBuilder.Entity() .HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId); // WithMany() with no inverse navigation: Company.Jobs is already the JobApplication // collection (a legacy name predating this split), so Job hangs off Company without // claiming it. Restrict rather than Cascade — deleting a company should not silently // destroy opportunity records that applications may still reference. modelBuilder.Entity() .HasOne(j => j.Company) .WithMany() .HasForeignKey(j => j.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(j => j.OwnerUserId); // SetNull, not Cascade: an application must survive its Job row being removed, since // JobApplication still carries its own copy of the opportunity columns during the // Phase 0 -> Phase 1 transition. modelBuilder.Entity() .HasOne(j => j.Job) .WithMany(o => o.Applications) .HasForeignKey(j => j.JobId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasKey(x => x.OwnerUserId); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasData(new RuleSettings { Id = 1 }); modelBuilder.Entity() .HasOne(j => j.Company) .WithMany(c => c.Jobs) .HasForeignKey(j => j.CompanyId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasIndex(j => j.OwnerUserId); // Owner-prefixed composite indexes for the tenant-scoped hot paths. Every // JobApplication query is scoped by the OwnerUserId global filter first, then // filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders). // Status is intentionally excluded from the index because Pomelo maps the // unbounded string column to longtext, which MariaDB cannot index without a // prefix length. The actual index DDL is applied idempotently in // StartupInitializationExtensions (this repo provisions schema via that // reconciler, not via the EF ModelSnapshot, which is stale). modelBuilder.Entity() .HasIndex(j => new { j.OwnerUserId, j.IsDeleted }); modelBuilder.Entity() .HasIndex(j => new { j.OwnerUserId, j.FollowUpAt }); // Board/list endpoints that filter by both IsDeleted and Status. Same MySQL // longtext-prefix caveat as above; the reconciler applies `Status(50)` there. modelBuilder.Entity() .HasIndex(j => new { j.OwnerUserId, j.IsDeleted, j.Status }); modelBuilder.Entity() .HasIndex(c => c.OwnerUserId); modelBuilder.Entity() .HasQueryFilter(c => CurrentUserId != null && c.JobApplication.OwnerUserId == CurrentUserId) .HasOne(c => c.JobApplication) .WithMany(j => j.Messages) .HasForeignKey(c => c.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasIndex(c => c.JobApplicationId); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Ignore(); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.GmailAddress }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.OwnerUserId); modelBuilder.Entity() .HasOne(a => a.JobApplication) .WithMany(j => j.Attachments) .HasForeignKey(a => a.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.JobApplication.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasOne(e => e.JobApplication) .WithMany(j => j.Events) .HasForeignKey(e => e.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasIndex(e => e.JobApplicationId); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc }); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc }); modelBuilder.Entity() .HasOne(x => x.Artifact) .WithMany() .HasForeignKey(x => x.ArtifactId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.TaskType).HasMaxLength(64); modelBuilder.Entity().Property(x => x.IdempotencyKey).HasMaxLength(128); modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); modelBuilder.Entity().Property(x => x.EntitlementDecision).HasMaxLength(32); modelBuilder.Entity().Property(x => x.PrivacyPolicy).HasMaxLength(32); modelBuilder.Entity().Property(x => x.SubjectType).HasMaxLength(64); modelBuilder.Entity().Property(x => x.SubjectId).HasMaxLength(128); modelBuilder.Entity().Property(x => x.Provider).HasMaxLength(128); modelBuilder.Entity().Property(x => x.Model).HasMaxLength(128); modelBuilder.Entity().Property(x => x.LeaseToken).HasMaxLength(32); modelBuilder.Entity().Property(x => x.ProgressStage).HasMaxLength(64); modelBuilder.Entity().Property(x => x.FailureCategory).HasMaxLength(64); modelBuilder.Entity().Property(x => x.FailureMessage).HasMaxLength(512); modelBuilder.Entity().Property(x => x.ResultReference).HasMaxLength(256); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.TaskType, x.IdempotencyKey }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.Status, x.AvailableAtUtc, x.Priority }); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Kind).HasMaxLength(64); modelBuilder.Entity().Property(x => x.Title).HasMaxLength(160); modelBuilder.Entity().Property(x => x.Message).HasMaxLength(512); modelBuilder.Entity().Property(x => x.LinkPath).HasMaxLength(256); modelBuilder.Entity() .HasIndex(x => x.OperationId) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.DismissedAtUtc, x.ReadAtUtc, x.CreatedAtUtc }); modelBuilder.Entity() .HasOne(x => x.Operation) .WithOne() .HasForeignKey(x => x.OperationId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Provider).HasMaxLength(32); modelBuilder.Entity().Property(x => x.ClientRequestId).HasMaxLength(128); modelBuilder.Entity().Property(x => x.PayloadHash).HasMaxLength(64); modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); modelBuilder.Entity().Property(x => x.ProviderMessageId).HasMaxLength(256); modelBuilder.Entity().Property(x => x.FailureCategory).HasMaxLength(64); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.ClientRequestId }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.CreatedAtUtc }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Provider).HasMaxLength(32); modelBuilder.Entity().Property(x => x.To).HasMaxLength(320); modelBuilder.Entity().Property(x => x.Subject).HasMaxLength(998); modelBuilder.Entity().Property(x => x.ThreadId).HasMaxLength(512); modelBuilder.Entity().Property(x => x.ClientRequestId).HasMaxLength(128); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.UpdatedAtUtc }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId }) .IsUnique(); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithOne(j => j.TailoredCvDraft) .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); // No FK to AspNetUsers: the login-time challenge endpoint reads these rows before a // session (and thus CurrentUserId) exists, via IgnoreQueryFilters() -- same convention // as AdminAuditController's cross-cutting queries. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.UserId, x.UsedAtUtc }); // No FK to AspNetUsers: the login-time trusted-device check reads these rows before a // session (and thus CurrentUserId) exists, via IgnoreQueryFilters() -- same convention // as TwoFactorRecoveryCode above. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => x.UserId); modelBuilder.Entity() .HasIndex(x => x.TokenHash); // No FK to AspNetUsers, same convention as TrustedDevice/TwoFactorRecoveryCode above: the // OnTokenValidated auth check reads this table before CurrentUserId is meaningfully set // for the request being validated, via IgnoreQueryFilters(). modelBuilder.Entity() .HasKey(x => x.Id); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => x.UserId); // Career Workspace foundation (docs/career-workspace-implementation-roadmap.md Phase F1). // One CareerProfile per user for now -- see roadmap "Not now: multiple profiles per user". modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => x.OwnerUserId) .IsUnique(); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version }); modelBuilder.Entity() .HasOne(x => x.CareerProfile) .WithMany() .HasForeignKey(x => x.CareerProfileId) .OnDelete(DeleteBehavior.Cascade); // Phase 3: relational children of CareerProfile (the editable master profile). // docs/architecture/career-profile-model.md. Same deny-on-null tenant filter as everything // else; cascade-delete with the parent; indexed by (OwnerUserId, CareerProfileId, SortOrder) // for the ordered per-profile reads the /career editor does. ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); // Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5). modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId }) .IsUnique(); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); // Generic AI workspace note persistence (candidate fit, focus plan) -- same rationale as // InterviewPrepNote above, generalized because these DTOs are irregular/nested enough that // per-field columns would be unreasonable. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType }) .IsUnique(); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); // Phase 4: CV variants (a lens over the master profile) + their autosave history. // docs/architecture/cv-builder.md. Same deny-on-null tenant filter as everything else. A // variant references a job application (reference-not-ownership, SetNull on delete) and never // owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); // Bound the indexed string columns so Pomelo maps them to varchar, not longtext (MariaDB // cannot index a TEXT/longtext column without a prefix length). Actual prod DDL is applied by // the reconciler (StartupInitializationExtensions), MySQL-safe; see that file + DbContext note. modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.PublicSlug).HasMaxLength(64); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity() .HasIndex(x => x.PublicSlug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.CvVariantId, x.Version }); modelBuilder.Entity() .HasOne(x => x.CvVariant) .WithMany() .HasForeignKey(x => x.CvVariantId) .OnDelete(DeleteBehavior.Cascade); // Phase 5: append-only AI interaction history per job application. Same deny-on-null tenant // filter; indexed for the per-job history read; cascades with the application. // docs/architecture/ai-career-assistant.md. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); // varchar (not longtext) for the indexed columns — see the CvVariant note above. modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Module).HasMaxLength(64); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.SourceType).HasMaxLength(32); modelBuilder.Entity().Property(x => x.SourceId).HasMaxLength(64); modelBuilder.Entity().Property(x => x.TaskType).HasMaxLength(64); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.SourceType, x.SourceId }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.CreatedAtUtc }); // Phase 5 Milestone 2: the application checklist — a workflow guidance layer over the existing // readiness signals, not a second store of truth. Same deny-on-null tenant filter; cascades with // the application. docs/architecture/application-workspace.md. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); // varchar (not longtext) for the indexed columns — see the CvVariant note above. modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.SystemKey).HasMaxLength(64); modelBuilder.Entity().Property(x => x.AutoSignal).HasMaxLength(64); modelBuilder.Entity().Property(x => x.Category).HasMaxLength(32); modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); modelBuilder.Entity().Property(x => x.Section).HasMaxLength(64); modelBuilder.Entity().Property(x => x.Title).HasMaxLength(255); // Seeding is idempotent per (application, system key) — the unique index is what enforces it. modelBuilder.Entity() .HasIndex(x => new { x.JobApplicationId, x.SystemKey }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.SortOrder }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); // Phase 5.4: append-only cover letter history. JobApplication.CoverLetterText stays the // current text; this makes every previous state recoverable. // docs/architecture/application-workspace.md. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); // varchar (not longtext) for the indexed columns — see the CvVariant note above. modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Source).HasMaxLength(32); modelBuilder.Entity().Property(x => x.AiAction).HasMaxLength(32); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Version }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); // Phase 5.5: user-owned interview preparation. Unlike InterviewPrepNote (an AI cache), nothing // regenerates this. docs/architecture/application-workspace.md. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); // varchar (not longtext) for the indexed columns — see the CvVariant note above. modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.Category).HasMaxLength(32); modelBuilder.Entity().Property(x => x.Source).HasMaxLength(16); modelBuilder.Entity().Property(x => x.Title).HasMaxLength(500); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.SortOrder }); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); } // Common config for CareerProfile's relational children. The 1:many FK + cascade delete is // wired by convention (each child's CareerProfileId + CareerProfile nav match the typed // collection on CareerProfile); this adds the tenant query filter and the ordered read index. private void ConfigureCareerChild(ModelBuilder modelBuilder) where T : Models.CareerChildEntity { 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 }); } } }