Files
jobtrackingapp/Data/JobTrackerContext.cs
T
cesnimda 235e291d8f feat: add career profile foundation with versioned history
Introduces the Career Workspace's bounded data foundation, additive
and backwards-compatible: ApplicationUser.ProfileCvStructureJson
stays the authoritative column every existing read path uses; the new
CareerProfiles/CareerProfileVersions tables mirror it via
ICareerProfileService so future Career Workspace features (variants,
history UI) have real tables to build on rather than starting a
second migration later.

- CareerProfile: one snapshot row per user (Version, ProfileJson).
- CareerProfileVersion: append-only history, one row per save
  (upload/rebuild/improve/reprocess/parse), so a profile edit is never
  silently lost the way ProfileCvStructureJson overwrites are today.
- Stable item IDs assigned to jobs/education/certifications/projects
  on first save and preserved across later saves -- the prerequisite
  for CV variants to reference "this job" by identity instead of
  array position.
- CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for
  job/education/certification/project date ranges, kept alongside
  (never replacing) the original free-string fields.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects,
  matching this repo's schema-via-raw-SQL-reconciler convention
  rather than EF migrations.

Job tracking is untouched -- this is entirely within the profile/CV
domain per the Career Workspace product boundary.
2026-07-12 15:18:05 +02:00

170 lines
7.3 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<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<CareerProfile> CareerProfiles => Set<CareerProfile>();
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
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);
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 });
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<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<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);
// 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);
}
}
}