Files
jobtrackingapp/Data/JobTrackerContext.cs
T
cesnimda 00a035ea20 feat: persist candidate fit and focus plan, stop re-running on every open
Extends the interview-prep persistence pattern (previous commit) to
the other two AI-generated per-job outputs that were re-running their
full AI call chain on every tab open: candidate-fit (4 AI calls) and
focus-plan (4 AI calls). Across all three tabs that's 9 AI calls fired
every single time a user revisits a job's AI workspace tabs.

Generalized into AiWorkspaceNote (OwnerUserId, JobApplicationId,
NoteType, ResultJson) rather than duplicating InterviewPrepNote's
per-field-column shape: CandidateFitDto and FocusPlanDto are irregular
and nested (up to 13 fields including a nested guidance object),
where per-field columns would be unreasonable. One table, keyed by
note type, serving both.

Same rules as interview prep: reuse across calls, regenerate when the
attachment selection changes, regenerate on explicit refresh. Frontend
gets the same "Regenerate" button on both tabs.

3 new tests (persist+reuse for both, refresh for candidate-fit).
Verified against the real dev DB.
2026-07-12 15:41:58 +02:00

202 lines
8.8 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>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
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);
// 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);
}
}
}