7f426e255c
A completely empty MariaDB database could not start: the reconciler assumed migration-owned tables already existed, and migrations assumed reconciler-owned tables already existed. Neither could go first. Existing databases worked, so only fresh installs were affected. Startup is now an explicit sequence: connect, reconcile, migrate, reconcile, start. The reconciler runs twice because neither position alone works — pass 1 repairs legacy schemas and creates the reconciler-owned tables that migrations reference, pass 2 picks up everything that could not exist yet on a fresh database. Every statement is existence-guarded, so the second pass is a no-op scan on a correct database. Untangled the overlapping ownership: - RuleSettings is migration-owned. The reconciler also created it, which made a clean install fail with "Table 'RuleSettings' already exists". It now only seeds the default row, and only once the table exists. - The six CareerProfile child tables are reconciler-owned. Their migration was scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to varchar(255) in the model so the index fits. - Reconciler tables that reference another table are guarded on their parent, so pass 1 skips them on an empty database instead of failing on the foreign key. - All index creation goes through one EnsureMySqlIndex helper, guarded on table existence as well as index existence. This removes ten copies of the raw block that crashed on a missing table. - The DbContext-owned connection is no longer disposed by the reconciler, and Open() is guarded on connection state, so the second pass can reuse it. Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts), restart on the populated database (idempotent, rows preserved), empty MariaDB via the Docker image, fresh SQLite (42 tables), and an existing partially migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications and 8 companies intact). 329 backend tests pass in Release. Ownership rules, startup order, fresh install and production upgrade are documented in docs/infrastructure/database-ownership.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
390 lines
20 KiB
C#
390 lines
20 KiB
C#
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using JobTrackerApi.Models;
|
|
|
|
namespace JobTrackerApi.Data
|
|
{
|
|
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
|
|
{
|
|
public string? CurrentUserId { get; }
|
|
|
|
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
|
|
{
|
|
CurrentUserId = currentUser.UserId;
|
|
}
|
|
|
|
public DbSet<Company> Companies => Set<Company>();
|
|
public DbSet<Job> Jobs => Set<Job>();
|
|
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
|
|
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
|
|
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
|
|
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
|
|
public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>();
|
|
public DbSet<ImapConnection> ImapConnections => Set<ImapConnection>();
|
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
|
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
|
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
|
public DbSet<SystemEmailSettings> SystemEmailSettings => Set<SystemEmailSettings>();
|
|
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
|
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
|
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
|
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
|
public DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();
|
|
public DbSet<TrustedDevice> TrustedDevices => Set<TrustedDevice>();
|
|
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
|
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
|
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
|
public DbSet<CareerExperience> CareerExperiences => Set<CareerExperience>();
|
|
public DbSet<CareerEducation> CareerEducations => Set<CareerEducation>();
|
|
public DbSet<CareerSkill> CareerSkills => Set<CareerSkill>();
|
|
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
|
|
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
|
|
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
|
|
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
|
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
|
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
|
|
public DbSet<CvVariantVersion> CvVariantVersions => Set<CvVariantVersion>();
|
|
public DbSet<AiInteraction> AiInteractions => Set<AiInteraction>();
|
|
public DbSet<ApplicationChecklistItem> ApplicationChecklistItems => Set<ApplicationChecklistItem>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
modelBuilder.Entity<Company>()
|
|
.HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.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<Job>()
|
|
.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<Job>()
|
|
.HasOne(j => j.Company)
|
|
.WithMany()
|
|
.HasForeignKey(j => j.CompanyId)
|
|
.OnDelete(DeleteBehavior.Restrict);
|
|
|
|
modelBuilder.Entity<Job>()
|
|
.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<JobApplication>()
|
|
.HasOne(j => j.Job)
|
|
.WithMany(o => o.Applications)
|
|
.HasForeignKey(j => j.JobId)
|
|
.OnDelete(DeleteBehavior.SetNull);
|
|
|
|
modelBuilder.Entity<UserRuleSettings>()
|
|
.HasKey(x => x.OwnerUserId);
|
|
|
|
modelBuilder.Entity<UserRuleSettings>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<RuleSettings>()
|
|
.HasData(new RuleSettings { Id = 1 });
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.HasOne(j => j.Company)
|
|
.WithMany(c => c.Jobs)
|
|
.HasForeignKey(j => j.CompanyId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.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<JobApplication>()
|
|
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.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<JobApplication>()
|
|
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted, j.Status });
|
|
|
|
modelBuilder.Entity<Company>()
|
|
.HasIndex(c => c.OwnerUserId);
|
|
|
|
modelBuilder.Entity<Correspondence>()
|
|
.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<Correspondence>()
|
|
.HasIndex(c => c.JobApplicationId);
|
|
|
|
modelBuilder.Entity<GmailConnection>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<GmailReviewDecision>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
|
|
|
modelBuilder.Entity<GmailConnection>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<GmailConnection>()
|
|
.HasIndex(x => x.OwnerUserId);
|
|
|
|
modelBuilder.Entity<Attachment>()
|
|
.HasOne(a => a.JobApplication)
|
|
.WithMany(j => j.Attachments)
|
|
.HasForeignKey(a => a.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<JobEvent>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.JobApplication.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<JobEvent>()
|
|
.HasOne(e => e.JobApplication)
|
|
.WithMany(j => j.Events)
|
|
.HasForeignKey(e => e.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<JobEvent>()
|
|
.HasIndex(e => e.JobApplicationId);
|
|
|
|
modelBuilder.Entity<CvUploadArtifact>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<CvUploadArtifact>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc });
|
|
|
|
modelBuilder.Entity<CvExtractionRun>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<CvExtractionRun>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
|
|
|
|
modelBuilder.Entity<CvExtractionRun>()
|
|
.HasOne(x => x.Artifact)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.ArtifactId)
|
|
.OnDelete(DeleteBehavior.SetNull);
|
|
|
|
modelBuilder.Entity<TailoredCvDraft>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<TailoredCvDraft>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<TailoredCvDraft>()
|
|
.HasOne(x => x.JobApplication)
|
|
.WithOne(j => j.TailoredCvDraft)
|
|
.HasForeignKey<TailoredCvDraft>(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<TwoFactorRecoveryCode>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<TwoFactorRecoveryCode>()
|
|
.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<TrustedDevice>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<TrustedDevice>()
|
|
.HasIndex(x => x.UserId);
|
|
|
|
modelBuilder.Entity<TrustedDevice>()
|
|
.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<UserSession>()
|
|
.HasKey(x => x.Id);
|
|
|
|
modelBuilder.Entity<UserSession>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<UserSession>()
|
|
.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<CareerProfile>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<CareerProfile>()
|
|
.HasIndex(x => x.OwnerUserId)
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<CareerProfileVersion>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<CareerProfileVersion>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version });
|
|
|
|
modelBuilder.Entity<CareerProfileVersion>()
|
|
.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<CareerExperience>(modelBuilder);
|
|
ConfigureCareerChild<CareerEducation>(modelBuilder);
|
|
ConfigureCareerChild<CareerSkill>(modelBuilder);
|
|
ConfigureCareerChild<CareerProject>(modelBuilder);
|
|
ConfigureCareerChild<CareerCertification>(modelBuilder);
|
|
ConfigureCareerChild<CareerLanguage>(modelBuilder);
|
|
|
|
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
|
|
modelBuilder.Entity<InterviewPrepNote>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<InterviewPrepNote>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<InterviewPrepNote>()
|
|
.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<AiWorkspaceNote>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<AiWorkspaceNote>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType })
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<AiWorkspaceNote>()
|
|
.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<CvVariant>()
|
|
.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<CvVariant>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<CvVariant>().Property(x => x.PublicSlug).HasMaxLength(64);
|
|
modelBuilder.Entity<CvVariantVersion>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasIndex(x => x.PublicSlug)
|
|
.IsUnique();
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasOne(x => x.JobApplication)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.SetNull);
|
|
|
|
modelBuilder.Entity<CvVariantVersion>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
modelBuilder.Entity<CvVariantVersion>()
|
|
.HasIndex(x => new { x.CvVariantId, x.Version });
|
|
modelBuilder.Entity<CvVariantVersion>()
|
|
.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<AiInteraction>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
// varchar (not longtext) for the indexed columns — see the CvVariant note above.
|
|
modelBuilder.Entity<AiInteraction>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<AiInteraction>().Property(x => x.Module).HasMaxLength(64);
|
|
modelBuilder.Entity<AiInteraction>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc });
|
|
modelBuilder.Entity<AiInteraction>()
|
|
.HasOne(x => x.JobApplication)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
// 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<ApplicationChecklistItem>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
// varchar (not longtext) for the indexed columns — see the CvVariant note above.
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.SystemKey).HasMaxLength(64);
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.AutoSignal).HasMaxLength(64);
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.Category).HasMaxLength(32);
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.Status).HasMaxLength(32);
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.Section).HasMaxLength(64);
|
|
modelBuilder.Entity<ApplicationChecklistItem>().Property(x => x.Title).HasMaxLength(255);
|
|
// Seeding is idempotent per (application, system key) — the unique index is what enforces it.
|
|
modelBuilder.Entity<ApplicationChecklistItem>()
|
|
.HasIndex(x => new { x.JobApplicationId, x.SystemKey })
|
|
.IsUnique();
|
|
modelBuilder.Entity<ApplicationChecklistItem>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.SortOrder });
|
|
modelBuilder.Entity<ApplicationChecklistItem>()
|
|
.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<T>(ModelBuilder modelBuilder) where T : Models.CareerChildEntity
|
|
{
|
|
modelBuilder.Entity<T>()
|
|
.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<T>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<T>().Property(x => x.ItemKey).HasMaxLength(255);
|
|
|
|
modelBuilder.Entity<T>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.SortOrder });
|
|
}
|
|
}
|
|
}
|