Files
Inboxintel/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
T
cesnimda 2056548702
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 46s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 56s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s
feat(ai): pgvector embedding infrastructure (#19)
2026-07-02 03:00:06 +02:00

115 lines
6.4 KiB
C#

using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
namespace InboxIntel.Infrastructure.Persistence;
public class AppDbContext : DbContext, IAppDbContext
{
private readonly ICurrentUser? _currentUser;
public AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUser? currentUser = null) : base(options)
=> _currentUser = currentUser;
/// <summary>
/// Tenant id used by the global query filters below. Resolves to the
/// authenticated user during an HTTP request. It is <see cref="Guid.Empty"/>
/// when there is no current user (background workers, design-time tooling,
/// startup migration) — in which case filtering is DISABLED, because those
/// paths are trusted server code that already scope their own queries by a
/// userId passed in explicitly. The security value is on the HTTP attack
/// surface: a forgotten manual <c>WHERE UserId ==</c> can no longer leak
/// another tenant's rows, since the filter restricts to the caller.
/// </summary>
public Guid CurrentUserId => _currentUser?.UserId ?? Guid.Empty;
public DbSet<User> Users => Set<User>();
public DbSet<Email> Emails => Set<Email>();
public DbSet<MailThread> Threads => Set<MailThread>();
public DbSet<Sender> Senders => Set<Sender>();
public DbSet<MailDomain> Domains => Set<MailDomain>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<Label> Labels => Set<Label>();
public DbSet<EmailLabel> EmailLabels => Set<EmailLabel>();
public DbSet<SyncState> SyncStates => Set<SyncState>();
public DbSet<AnalyticsAggregate> AnalyticsAggregates => Set<AnalyticsAggregate>();
public DbSet<WidgetLayout> WidgetLayouts => Set<WidgetLayout>();
public DbSet<UnsubscribeItem> UnsubscribeItems => Set<UnsubscribeItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
// Defense-in-depth tenant isolation (systemic IDOR safeguard). Every
// user-owned entity is filtered to the current user so a query that forgets
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// PostgreSQL full-text search: generated tsvector over subject + body with a
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
// and body 'B' so ts_rank_cd ranks a subject match above a body-only mention. The
// tsvector type is Npgsql-only, so map it only for relational providers and ignore
// it otherwise (e.g. the InMemory provider used by tests).
if (Database.IsRelational())
{
// pg_trgm powers the fuzzy/typo fallback in SearchService (word_similarity).
modelBuilder.HasPostgresExtension("pg_trgm");
modelBuilder.Entity<Email>().Property(e => e.SearchVector)
.HasColumnType("tsvector")
.HasComputedColumnSql(
"setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || " +
"setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
stored: true);
modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN");
// Trigram GIN indexes so the sender/domain `.Contains()` filters in SearchService
// (which translate to `LIKE '%x%'`, non-sargable on a btree) become index-accelerated.
// Needs pg_trgm (enabled by the EnablePgTrgm migration).
modelBuilder.Entity<Sender>().HasIndex(s => s.Address)
.HasMethod("gin").HasOperators("gin_trgm_ops");
modelBuilder.Entity<Sender>().HasIndex(s => s.DisplayName)
.HasMethod("gin").HasOperators("gin_trgm_ops");
modelBuilder.Entity<MailDomain>().HasIndex(d => d.Name)
.HasMethod("gin").HasOperators("gin_trgm_ops");
// pgvector: 768-dim embedding for semantic search, with an HNSW cosine index.
// Populated by the embedding backfill worker when AI is enabled; null otherwise.
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<Email>().Property(e => e.Embedding).HasColumnType("vector(768)");
modelBuilder.Entity<Email>().HasIndex(e => e.Embedding)
.HasMethod("hnsw").HasOperators("vector_cosine_ops");
}
else
{
modelBuilder.Entity<Email>().Ignore(e => e.SearchVector);
modelBuilder.Entity<Email>().Ignore(e => e.Embedding);
}
base.OnModelCreating(modelBuilder);
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Modified)
entry.Entity.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return base.SaveChangesAsync(ct);
}
}