Files
jobtrackingapp/Data/JobTrackerContext.cs
T
cesnimda eac34705e3 feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.

Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
  JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
  tables the scaffolder re-emitted; verified against the real dev DB.

Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).

DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.

AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.

Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.

Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:05:25 +02:00

222 lines
9.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<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>();
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);
}
}
}