using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Models; namespace JobTrackerApi.Data { public class JobTrackerContext : IdentityDbContext { public string? CurrentUserId { get; } public JobTrackerContext(DbContextOptions options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options) { CurrentUserId = currentUser.UserId; } public DbSet Companies => 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 CareerProfiles => Set(); public DbSet CareerProfileVersions => Set(); public DbSet InterviewPrepNotes => Set(); public DbSet AiWorkspaceNotes => Set(); public DbSet CvVariants => Set(); public DbSet CvVersions => Set(); public DbSet TailoredApplications => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity() .HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId); 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 }); 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() .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() .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() .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); // 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); // 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); // CV variants (career-workspace-implementation-roadmap.md Phase F2). A variant is not // owned by a job -- it survives job deletion and can be reused across applications; only // TailoredApplication (the reference) is job-scoped. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => x.OwnerUserId); modelBuilder.Entity() .HasOne(x => x.CareerProfile) .WithMany() .HasForeignKey(x => x.CareerProfileId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.CvVariantId, x.Version }); modelBuilder.Entity() .HasOne(x => x.CvVariant) .WithMany() .HasForeignKey(x => x.CvVariantId) .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.CvVariant) .WithMany() .HasForeignKey(x => x.CvVariantId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(x => x.JobApplication) .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); } } }