5916f09852
Interview prep re-ran its AI call every time the tab opened -- flagged in the product teardown as work evaporating on every re-open (cost, latency, and non-determinism for no reason). GetInterviewPrep now persists one note per job application and reuses it on subsequent reads, only regenerating when the selected attachment context changes or a refresh is explicitly requested. - InterviewPrepNote: one row per (owner, job), keyed additionally by an attachment-selection fingerprint so picking different attachments correctly triggers a fresh brief without needing an explicit flag. - GetInterviewPrep gained a `refresh` query param; the frontend adds a small "Regenerate" button as the explicit escape hatch for when the underlying job/notes have changed since the note was written. - Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects. - 3 new tests: reuse across calls, refresh regenerates, attachment context change regenerates. Verified against the real dev DB.
185 lines
8.0 KiB
C#
185 lines
8.0 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>();
|
|
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
|
|
|
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);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|