diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs index 60b4170..3a84c90 100644 --- a/src/InboxIntel.Application/Abstractions/IServices.cs +++ b/src/InboxIntel.Application/Abstractions/IServices.cs @@ -120,3 +120,15 @@ public interface IDigestService { Task SendDigestAsync(Guid userId, CancellationToken ct = default); } + +/// System feature flags (fail-closed: unknown key = disabled). +public interface IFeatureFlags +{ + Task IsEnabledAsync(string key, CancellationToken ct = default); +} + +/// Policy gate for AI features: system flag AND the user's opt-in. +public interface IAiGate +{ + Task IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default); +} diff --git a/src/InboxIntel.Domain/Entities/FeatureFlag.cs b/src/InboxIntel.Domain/Entities/FeatureFlag.cs new file mode 100644 index 0000000..2e7ea8a --- /dev/null +++ b/src/InboxIntel.Domain/Entities/FeatureFlag.cs @@ -0,0 +1,42 @@ +using InboxIntel.Domain.Common; + +namespace InboxIntel.Domain.Entities; + +/// +/// System-wide feature flag (docs/discovery/multi-provider/04). The admin master switches: +/// a disabled flag turns its feature off for EVERYONE regardless of user preferences. +/// Reads are fail-closed — an unknown key counts as disabled. +/// +public class FeatureFlag : AuditableEntity +{ + /// Stable key, e.g. "ai.enabled", "provider.google". + public string Key { get; set; } = string.Empty; + + public bool Enabled { get; set; } + + /// True = a user preference may turn the feature OFF for themselves + /// (never on beyond the flag); false = system-only switch. + public bool UserOverridable { get; set; } + + public string? Description { get; set; } +} + +/// +/// Per-user preferences (docs/discovery/multi-provider/04). One row per user, created +/// lazily; absent row = defaults. AiOptIn defaults true so enabling the ai.enabled flag +/// behaves exactly like today until a user opts out. +/// +public class UserSetting : AuditableEntity +{ + public Guid UserId { get; set; } + public User? User { get; set; } + + /// "system" | "light" | "dark". + public string Theme { get; set; } = "dark"; + + /// Master per-user AI opt-in (effective only while ai.enabled is on). + public bool AiOptIn { get; set; } = true; + + /// Free-form UI preferences (layout, density, notifications) as JSON. + public string? PreferencesJson { get; set; } +} diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs index 9bd0be3..2724714 100644 --- a/src/InboxIntel.Infrastructure/DependencyInjection.cs +++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs @@ -92,6 +92,11 @@ public static class DependencyInjection break; } services.AddScoped(); + // Feature flags + AI policy gate (docs/discovery/multi-provider/04). Cached 15s, + // fail-closed. Admin toggle surface arrives with the multi-provider admin phase. + services.AddMemoryCache(); + services.AddScoped(); + services.AddScoped(); // Semantic search: fills Email.Embedding in the background; no-ops when the // embedding provider is unavailable (AI disabled), so lexical search is unaffected. services.AddHostedService(); diff --git a/src/InboxIntel.Infrastructure/Features/FeatureFlagService.cs b/src/InboxIntel.Infrastructure/Features/FeatureFlagService.cs new file mode 100644 index 0000000..4a91382 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Features/FeatureFlagService.cs @@ -0,0 +1,72 @@ +using InboxIntel.Application.Abstractions; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace InboxIntel.Infrastructure.Features; + +/// +/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an +/// admin toggle takes effect within seconds and per-request reads stay free. +/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled. +/// +public class FeatureFlagService : IFeatureFlags +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15); + private readonly AppDbContext _db; + private readonly IMemoryCache _cache; + + public FeatureFlagService(AppDbContext db, IMemoryCache cache) + { + _db = db; + _cache = cache; + } + + public async Task IsEnabledAsync(string key, CancellationToken ct = default) + { + try + { + var flags = await _cache.GetOrCreateAsync("feature-flags", async e => + { + e.AbsoluteExpirationRelativeToNow = CacheTtl; + return await _db.FeatureFlags.AsNoTracking() + .ToDictionaryAsync(f => f.Key, f => f.Enabled, ct); + }); + return flags is not null && flags.TryGetValue(key, out var enabled) && enabled; + } + catch + { + return false; // fail closed + } + } +} + +/// +/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user +/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true, +/// only consulted while the flag is on). Callers still check provider availability +/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing. +/// +public class AiGate : IAiGate +{ + public const string MasterFlag = "ai.enabled"; + private readonly IFeatureFlags _flags; + private readonly AppDbContext _db; + + public AiGate(IFeatureFlags flags, AppDbContext db) + { + _flags = flags; + _db = db; + } + + public async Task IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default) + { + if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false; + // Absent settings row = default opt-in true. + var optIn = await _db.UserSettings.AsNoTracking() + .Where(s => s.UserId == userId) + .Select(s => (bool?)s.AiOptIn) + .FirstOrDefaultAsync(ct); + return optIn ?? true; + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs new file mode 100644 index 0000000..a2d7e12 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs @@ -0,0 +1,831 @@ +// +using System; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; +using Pgvector; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260702152850_FeatureFlagsAndUserSettings")] + partial class FeatureFlagsAndUserSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Day") + .HasColumnType("date"); + + b.Property("HourHistogramJson") + .HasColumnType("text"); + + b.Property("NewsletterCount") + .HasColumnType("integer"); + + b.Property("TotalReceived") + .HasColumnType("integer"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("TotalUnread") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WithAttachments") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Day") + .IsUnique(); + + b.ToTable("analytics_aggregates", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("GmailAttachmentId") + .HasColumnType("text"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.HasIndex("UserId", "MimeType"); + + b.ToTable("attachments", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Embedding") + .HasColumnType("vector(768)"); + + b.Property("GmailMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasAttachments") + .HasColumnType("boolean"); + + b.Property("HasListUnsubscribe") + .HasColumnType("boolean"); + + b.Property("IsImportant") + .HasColumnType("boolean"); + + b.Property("IsInInbox") + .HasColumnType("boolean"); + + b.Property("IsStarred") + .HasColumnType("boolean"); + + b.Property("IsTrashed") + .HasColumnType("boolean"); + + b.Property("IsUnread") + .HasColumnType("boolean"); + + b.Property("ListUnsubscribeRaw") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SizeEstimateBytes") + .HasColumnType("bigint"); + + b.Property("Snippet") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("SupportsOneClickUnsubscribe") + .HasColumnType("boolean"); + + b.Property("ThreadId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); + + b.HasIndex("SenderId"); + + b.HasIndex("ThreadId"); + + b.HasIndex("UserId", "Category"); + + b.HasIndex("UserId", "GmailMessageId") + .IsUnique(); + + b.HasIndex("UserId", "IsInInbox"); + + b.HasIndex("UserId", "IsUnread"); + + b.HasIndex("UserId", "SenderId"); + + b.HasIndex("UserId", "SentAtUtc"); + + b.ToTable("emails", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("LabelId") + .HasColumnType("uuid"); + + b.HasKey("EmailId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("email_labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserOverridable") + .HasColumnType("boolean"); + + b.HasKey("Key"); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ColorHex") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailLabelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailLabelId") + .IsUnique(); + + b.ToTable("labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("IsBulkSender") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("domains", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailThreadId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Snippet") + .HasColumnType("text"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailThreadId") + .IsUnique(); + + b.ToTable("threads", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("HasUnsubscribe") + .HasColumnType("boolean"); + + b.Property("LastReceivedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("UnreadCount") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" }); + + b.HasIndex("DisplayName"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" }); + + b.HasIndex("DomainId"); + + b.HasIndex("UserId", "Address") + .IsUnique(); + + b.HasIndex("UserId", "EmailCount"); + + b.ToTable("senders", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("LastHistoryId") + .HasColumnType("text"); + + b.Property("LastSuccessfulSyncUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncType") + .HasColumnType("integer"); + + b.Property("MessagesProcessed") + .HasColumnType("integer"); + + b.Property("ResumePageToken") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalMessagesEstimate") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("sync_states", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("double precision"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("LastAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UnsubscribeTarget") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("UserId", "SenderId") + .IsUnique(); + + b.ToTable("unsubscribe_items", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessTokenExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DigestEnabled") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EncryptedRefreshToken") + .HasColumnType("bytea"); + + b.Property("GoogleSubjectId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastDigestSentUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PictureUrl") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("GoogleSubjectId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("AiOptIn") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreferencesJson") + .HasColumnType("text"); + + b.Property("Theme") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("H") + .HasColumnType("integer"); + + b.Property("SettingsJson") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.Property("W") + .HasColumnType("integer"); + + b.Property("WidgetKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "WidgetKey") + .IsUnique(); + + b.ToTable("widget_layouts", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany("Emails") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread") + .WithMany("Emails") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("Emails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + + b.Navigation("Thread"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("EmailLabels") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.Label", "Label") + .WithMany("EmailLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain") + .WithMany("Senders") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Domain"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("WidgetLayouts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Navigation("Attachments"); + + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Navigation("Senders"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Navigation("Emails"); + + b.Navigation("WidgetLayouts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs new file mode 100644 index 0000000..013fee7 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + /// + public partial class FeatureFlagsAndUserSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "feature_flags", + columns: table => new + { + Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + UserOverridable = table.Column(type: "boolean", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_feature_flags", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "user_settings", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + Theme = table.Column(type: "text", nullable: false), + AiOptIn = table.Column(type: "boolean", nullable: false), + PreferencesJson = table.Column(type: "text", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_user_settings", x => x.UserId); + table.ForeignKey( + name: "FK_user_settings_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + // Behaviour-preserving defaults (docs/discovery/multi-provider/04): ai.enabled on + // (AI availability still requires Ai:Mode + provider), Google on, others off. + migrationBuilder.Sql(""" + INSERT INTO feature_flags ("Key", "Enabled", "UserOverridable", "Description", "CreatedAtUtc", "UpdatedAtUtc") + VALUES + ('ai.enabled', TRUE, TRUE, 'Master AI switch: off hides AI for everyone', NOW(), NOW()), + ('provider.google', TRUE, FALSE, 'Google/Gmail provider', NOW(), NOW()), + ('provider.microsoft', FALSE, FALSE, 'Microsoft/Outlook provider (future)', NOW(), NOW()), + ('provider.imap', FALSE, FALSE, 'IMAP provider (future)', NOW(), NOW()) + ON CONFLICT ("Key") DO NOTHING; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "feature_flags"); + + migrationBuilder.DropTable( + name: "user_settings"); + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 41db34c..c0bb4e9 100644 --- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ namespace InboxIntel.Infrastructure.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); @@ -243,6 +243,32 @@ namespace InboxIntel.Infrastructure.Migrations b.ToTable("email_labels", (string)null); }); + modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserOverridable") + .HasColumnType("boolean"); + + b.HasKey("Key"); + + b.ToTable("feature_flags", (string)null); + }); + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => { b.Property("Id") @@ -591,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations b.ToTable("users", (string)null); }); + modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("AiOptIn") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreferencesJson") + .HasColumnType("text"); + + b.Property("Theme") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("user_settings", (string)null); + }); + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => { b.Property("Id") @@ -717,6 +769,17 @@ namespace InboxIntel.Infrastructure.Migrations b.Navigation("Sender"); }); + modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => { b.HasOne("InboxIntel.Domain.Entities.User", null) diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs index d09a08b..7a33a96 100644 --- a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs +++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs @@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext public DbSet Emails => Set(); public DbSet Threads => Set(); public DbSet Senders => Set(); + public DbSet FeatureFlags => Set(); + public DbSet UserSettings => Set(); public DbSet Domains => Set(); public DbSet Attachments => Set(); public DbSet