1430313a20
Deploy failed on prod (MariaDB) while the test job was green: backend startup threw during Database.Migrate(), so deploy.sh's post-deploy health check exited. Root cause: AddCvVariants/AddAiInteractions were scaffolded against SQLite, so they bake SQLite type names into their DDL — DateTimeOffset emits `TEXT`, bool/int emit `INTEGER`, and the PK gets no AUTO_INCREMENT. Run against MariaDB that yields a structurally wrong table, and the composite index over a TEXT column then trips "ERROR 1071: Specified key was too long; max key length is 3072 bytes". SQLite accepts all of it, which is why local/container verification passed. Fix, following the pattern already used for CareerProfiles/AiWorkspaceNotes: - both migrations become no-ops; the three tables are reconciler-owned - reconciler provisions them idempotently per dialect (MySQL: varchar/int AUTO_INCREMENT/datetime(6); SQLite: CREATE TABLE IF NOT EXISTS) - DropMalformedMySqlTable rebuilds a half-built table left by the failed migration, guarded on row count so a table with ANY rows is never dropped - bound the indexed string columns with HasMaxLength so the model matches Verified against a real MariaDB 11 container: reproduced error 1071, then confirmed the corrected DDL yields auto_increment PKs, varchar/datetime columns and all previously-failing indexes. 306 backend tests green; SQLite container starts clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
358 lines
17 KiB
C#
358 lines
17 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>();
|
|
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
|
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
|
public DbSet<CareerExperience> CareerExperiences => Set<CareerExperience>();
|
|
public DbSet<CareerEducation> CareerEducations => Set<CareerEducation>();
|
|
public DbSet<CareerSkill> CareerSkills => Set<CareerSkill>();
|
|
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
|
|
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
|
|
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
|
|
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
|
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
|
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
|
|
public DbSet<CvVariantVersion> CvVariantVersions => Set<CvVariantVersion>();
|
|
public DbSet<AiInteraction> AiInteractions => Set<AiInteraction>();
|
|
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// Phase 3: relational children of CareerProfile (the editable master profile).
|
|
// docs/architecture/career-profile-model.md. Same deny-on-null tenant filter as everything
|
|
// else; cascade-delete with the parent; indexed by (OwnerUserId, CareerProfileId, SortOrder)
|
|
// for the ordered per-profile reads the /career editor does.
|
|
ConfigureCareerChild<CareerExperience>(modelBuilder);
|
|
ConfigureCareerChild<CareerEducation>(modelBuilder);
|
|
ConfigureCareerChild<CareerSkill>(modelBuilder);
|
|
ConfigureCareerChild<CareerProject>(modelBuilder);
|
|
ConfigureCareerChild<CareerCertification>(modelBuilder);
|
|
ConfigureCareerChild<CareerLanguage>(modelBuilder);
|
|
|
|
// 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);
|
|
|
|
// Phase 4: CV variants (a lens over the master profile) + their autosave history.
|
|
// docs/architecture/cv-builder.md. Same deny-on-null tenant filter as everything else. A
|
|
// variant references a job application (reference-not-ownership, SetNull on delete) and never
|
|
// owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously.
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
// Bound the indexed string columns so Pomelo maps them to varchar, not longtext (MariaDB
|
|
// cannot index a TEXT/longtext column without a prefix length). Actual prod DDL is applied by
|
|
// the reconciler (StartupInitializationExtensions), MySQL-safe; see that file + DbContext note.
|
|
modelBuilder.Entity<CvVariant>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<CvVariant>().Property(x => x.PublicSlug).HasMaxLength(64);
|
|
modelBuilder.Entity<CvVariantVersion>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasIndex(x => x.PublicSlug)
|
|
.IsUnique();
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
|
|
modelBuilder.Entity<CvVariant>()
|
|
.HasOne(x => x.JobApplication)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.SetNull);
|
|
|
|
modelBuilder.Entity<CvVariantVersion>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
modelBuilder.Entity<CvVariantVersion>()
|
|
.HasIndex(x => new { x.CvVariantId, x.Version });
|
|
modelBuilder.Entity<CvVariantVersion>()
|
|
.HasOne(x => x.CvVariant)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.CvVariantId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
// Phase 5: append-only AI interaction history per job application. Same deny-on-null tenant
|
|
// filter; indexed for the per-job history read; cascades with the application.
|
|
// docs/architecture/ai-career-assistant.md.
|
|
modelBuilder.Entity<AiInteraction>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
// varchar (not longtext) for the indexed columns — see the CvVariant note above.
|
|
modelBuilder.Entity<AiInteraction>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
|
modelBuilder.Entity<AiInteraction>().Property(x => x.Module).HasMaxLength(64);
|
|
modelBuilder.Entity<AiInteraction>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc });
|
|
modelBuilder.Entity<AiInteraction>()
|
|
.HasOne(x => x.JobApplication)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
}
|
|
|
|
// Common config for CareerProfile's relational children. The 1:many FK + cascade delete is
|
|
// wired by convention (each child's CareerProfileId + CareerProfile nav match the typed
|
|
// collection on CareerProfile); this adds the tenant query filter and the ordered read index.
|
|
private void ConfigureCareerChild<T>(ModelBuilder modelBuilder) where T : Models.CareerChildEntity
|
|
{
|
|
modelBuilder.Entity<T>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<T>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.SortOrder });
|
|
}
|
|
}
|
|
}
|