Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ac20b124 | |||
| a3785a45cb | |||
| 3333e19452 | |||
| 1e8feeef71 | |||
| 074d39d9a2 |
@@ -0,0 +1,13 @@
|
||||
# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with
|
||||
# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git
|
||||
# stores LF, and the pre-commit/CI format gates flip-flop forever.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.cs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -52,3 +52,21 @@ jobs:
|
||||
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
||||
working-directory: frontend
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
|
||||
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
|
||||
# don't look for. Advisory at first (not a required check); promote once tuned.
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The runner image lacks pip, and a semgrep job-container lacks the node that
|
||||
# actions/checkout needs — so install pip via apt on the standard image.
|
||||
- name: Install semgrep
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
|
||||
pipx install semgrep
|
||||
- name: Semgrep scan
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
|
||||
|
||||
@@ -53,6 +53,16 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# One-shot: ensure the DataProtection 'keys' volume is owned by the API's non-root
|
||||
# 'app' user (uid 1654). A volume created by an older root-running image is root-owned,
|
||||
# which makes the app fail to read its key ring and 500s on login. Runs as root, chowns,
|
||||
# exits; the api waits for it. Idempotent and cheap.
|
||||
init-keys:
|
||||
image: busybox
|
||||
command: ["sh", "-c", "chown -R 1654:1654 /keys"]
|
||||
volumes:
|
||||
- keys:/keys
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -79,6 +89,8 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
init-keys:
|
||||
condition: service_completed_successfully
|
||||
# V-08: bind to loopback so the API is not directly reachable from the network
|
||||
# (only via the frontend/nginx proxy over the internal compose network). This
|
||||
# prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers.
|
||||
|
||||
@@ -15,6 +15,14 @@ RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/p
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# The slim aspnet:10.0 image dropped libgssapi_krb5, which Npgsql tries to load during
|
||||
# connection negotiation ("Cannot load library libgssapi_krb5.so.2"). Harmless for password
|
||||
# auth but noisy and a latent failure on some paths — install the Kerberos runtime lib.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the
|
||||
|
||||
@@ -120,3 +120,15 @@ public interface IDigestService
|
||||
{
|
||||
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>System feature flags (fail-closed: unknown key = disabled).</summary>
|
||||
public interface IFeatureFlags
|
||||
{
|
||||
Task<bool> IsEnabledAsync(string key, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Policy gate for AI features: system flag AND the user's opt-in.</summary>
|
||||
public interface IAiGate
|
||||
{
|
||||
Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -23,4 +23,9 @@ public record SearchRequestDto(
|
||||
bool? IsTrashed = null,
|
||||
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
|
||||
string? Category = null, // EmailCategory name, e.g. "Finance"
|
||||
long? MinSizeBytes = null);
|
||||
long? MinSizeBytes = null,
|
||||
// Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last
|
||||
// row's SentAtUtc+Id to fetch the next window without OFFSET (O(pageSize), not O(page)).
|
||||
// When set, TotalCount is not recomputed (-1). Additive; offset paging still works.
|
||||
DateTimeOffset? AfterSentAtUtc = null,
|
||||
Guid? AfterId = null);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using InboxIntel.Domain.Common;
|
||||
|
||||
namespace InboxIntel.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class FeatureFlag : AuditableEntity
|
||||
{
|
||||
/// <summary>Stable key, e.g. "ai.enabled", "provider.google".</summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>True = a user preference may turn the feature OFF for themselves
|
||||
/// (never on beyond the flag); false = system-only switch.</summary>
|
||||
public bool UserOverridable { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class UserSetting : AuditableEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
/// <summary>"system" | "light" | "dark".</summary>
|
||||
public string Theme { get; set; } = "dark";
|
||||
|
||||
/// <summary>Master per-user AI opt-in (effective only while ai.enabled is on).</summary>
|
||||
public bool AiOptIn { get; set; } = true;
|
||||
|
||||
/// <summary>Free-form UI preferences (layout, density, notifications) as JSON.</summary>
|
||||
public string? PreferencesJson { get; set; }
|
||||
}
|
||||
@@ -92,6 +92,11 @@ public static class DependencyInjection
|
||||
break;
|
||||
}
|
||||
services.AddScoped<IAiService, AiService>();
|
||||
// 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<IFeatureFlags, Features.FeatureFlagService>();
|
||||
services.AddScoped<IAiGate, Features.AiGate>();
|
||||
// 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<EmbeddingBackfillWorker>();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Features;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<bool> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<bool> 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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
Generated
+831
@@ -0,0 +1,831 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Day")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("HourHistogramJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("NewsletterCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TotalUnread")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("GmailAttachmentId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BodyText")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("GmailMessageId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("HasAttachments")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasListUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsImportant")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTrashed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsUnread")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ListUnsubscribeRaw")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReceivedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchVector")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("SizeEstimateBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<bool>("SupportsOneClickUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("ThreadId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailLabelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsBulkSender")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("FirstMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailThreadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MessageCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("HasUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastReceivedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("UnreadCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("LastHistoryId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LastSyncType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MessagesProcessed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResumePageToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalMessagesEstimate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Method")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UnsubscribeTarget")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DigestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<byte[]>("EncryptedRefreshToken")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("GoogleSubjectId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastDigestSentUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PictureUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("H")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Visible")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("W")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WidgetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("X")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeatureFlagsAndUserSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "feature_flags",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UserOverridable = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
Theme = table.Column<string>(type: "text", nullable: false),
|
||||
AiOptIn = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PreferencesJson = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(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;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "feature_flags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -591,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("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)
|
||||
|
||||
@@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext
|
||||
public DbSet<Email> Emails => Set<Email>();
|
||||
public DbSet<MailThread> Threads => Set<MailThread>();
|
||||
public DbSet<Sender> Senders => Set<Sender>();
|
||||
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
|
||||
public DbSet<UserSetting> UserSettings => Set<UserSetting>();
|
||||
public DbSet<MailDomain> Domains => Set<MailDomain>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<Label> Labels => Set<Label>();
|
||||
@@ -61,6 +63,22 @@ public class AppDbContext : DbContext, IAppDbContext
|
||||
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UserSetting>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
|
||||
// Feature flags are system-wide (no tenant filter). Key is the natural PK.
|
||||
modelBuilder.Entity<FeatureFlag>(b =>
|
||||
{
|
||||
b.ToTable("feature_flags");
|
||||
b.HasKey(f => f.Key);
|
||||
b.Property(f => f.Key).HasMaxLength(128);
|
||||
});
|
||||
modelBuilder.Entity<UserSetting>(b =>
|
||||
{
|
||||
b.ToTable("user_settings");
|
||||
b.HasKey(x => x.UserId);
|
||||
// 1:1 with User sharing the PK — prevents a shadow UserId1 FK column.
|
||||
b.HasOne(x => x.User).WithOne().HasForeignKey<UserSetting>(x => x.UserId);
|
||||
});
|
||||
|
||||
// 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'
|
||||
|
||||
@@ -114,9 +114,23 @@ public class SearchService : ISearchService
|
||||
ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
||||
.ThenByDescending(e => e.SentAtUtc);
|
||||
else
|
||||
ranked = matched.OrderByDescending(e => e.SentAtUtc);
|
||||
ranked = matched.OrderByDescending(e => e.SentAtUtc).ThenByDescending(e => e.Id);
|
||||
|
||||
var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
||||
// Keyset (cursor) pagination for the browse path: O(pageSize) regardless of depth,
|
||||
// vs OFFSET's O(page*pageSize). The (SentAtUtc, Id) pair with the Id tie-break above
|
||||
// makes the ordering total, so windows never duplicate or skip rows.
|
||||
IQueryable<Email> paged;
|
||||
if (!hasFreeTextQuery && r is { AfterSentAtUtc: { } afterAt, AfterId: { } afterId })
|
||||
{
|
||||
paged = ranked
|
||||
.Where(e => e.SentAtUtc < afterAt || (e.SentAtUtc == afterAt && e.Id.CompareTo(afterId) < 0))
|
||||
.Take(r.PageSize);
|
||||
total = -1; // not recomputed on cursor windows (that's the point)
|
||||
}
|
||||
else
|
||||
{
|
||||
paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
||||
}
|
||||
|
||||
// "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no
|
||||
// highlight — a fuzzy hit has no literal match to headline). Unconditional projections
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Features;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Feature-flag + AI-gate contracts (docs/discovery/multi-provider/04):
|
||||
/// flags are FAIL-CLOSED, and the admin master switch (ai.enabled) beats any user opt-in.
|
||||
/// </summary>
|
||||
public class FeatureFlagTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
private static AppDbContext NewDb(string name) =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
|
||||
|
||||
private static FeatureFlagService Flags(AppDbContext db) =>
|
||||
new(db, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_flag_is_disabled_fail_closed()
|
||||
{
|
||||
using var db = NewDb(nameof(Unknown_flag_is_disabled_fail_closed));
|
||||
(await Flags(db).IsEnabledAsync("does.not.exist")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_flag_reads_true_disabled_reads_false()
|
||||
{
|
||||
using var db = NewDb(nameof(Enabled_flag_reads_true_disabled_reads_false));
|
||||
db.FeatureFlags.AddRange(
|
||||
new FeatureFlag { Key = "on.flag", Enabled = true },
|
||||
new FeatureFlag { Key = "off.flag", Enabled = false });
|
||||
await db.SaveChangesAsync();
|
||||
var flags = Flags(db);
|
||||
(await flags.IsEnabledAsync("on.flag")).Should().BeTrue();
|
||||
(await flags.IsEnabledAsync("off.flag")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ai_gate_master_flag_off_beats_user_opt_in()
|
||||
{
|
||||
using var db = NewDb(nameof(Ai_gate_master_flag_off_beats_user_opt_in));
|
||||
var user = Guid.NewGuid();
|
||||
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = false });
|
||||
db.UserSettings.Add(new UserSetting { UserId = user, AiOptIn = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
(await new AiGate(Flags(db), db).IsAiEnabledForUserAsync(user)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out()
|
||||
{
|
||||
using var db = NewDb(nameof(Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out));
|
||||
var optedOut = Guid.NewGuid();
|
||||
var noRow = Guid.NewGuid();
|
||||
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = true });
|
||||
db.UserSettings.Add(new UserSetting { UserId = optedOut, AiOptIn = false });
|
||||
await db.SaveChangesAsync();
|
||||
var gate = new AiGate(Flags(db), db);
|
||||
|
||||
(await gate.IsAiEnabledForUserAsync(noRow)).Should().BeTrue("no settings row = default opt-in");
|
||||
(await gate.IsAiEnabledForUserAsync(optedOut)).Should().BeFalse("explicit opt-out wins while the flag is on");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Application.DTOs;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using InboxIntel.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// RECOMMENDATIONS #8: keyset (cursor) pagination for the browse path — the window after a
|
||||
/// (SentAtUtc, Id) cursor returns the next rows with no duplicates/skips and no OFFSET scan.
|
||||
/// </summary>
|
||||
public class KeysetPaginationTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cursor_window_continues_exactly_after_the_previous_page()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(nameof(Cursor_window_continues_exactly_after_the_previous_page)).Options;
|
||||
|
||||
var baseline = DateTimeOffset.UtcNow;
|
||||
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
var sender = new Sender { UserId = user, Address = "s@x.x" };
|
||||
seed.Senders.Add(sender);
|
||||
for (var i = 0; i < 5; i++)
|
||||
seed.Emails.Add(new Email { UserId = user, GmailMessageId = $"m{i}", Sender = sender, SentAtUtc = baseline.AddMinutes(-i) });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user });
|
||||
var svc = new SearchService(ctx);
|
||||
|
||||
// First window via offset (page 1, size 2): m0, m1 (newest first).
|
||||
var page1 = await svc.SearchAsync(user, new SearchRequestDto(null, null, null, null, null, null, null, false, 1, 2));
|
||||
page1.Items.Select(i => i.GmailMessageId).Should().Equal("m0", "m1");
|
||||
|
||||
// Next window via cursor from the last row of page 1.
|
||||
var last = page1.Items[^1];
|
||||
var page2 = await svc.SearchAsync(user, new SearchRequestDto(
|
||||
null, null, null, null, null, null, null, false, 1, 2,
|
||||
AfterSentAtUtc: last.SentAtUtc, AfterId: last.Id));
|
||||
|
||||
page2.Items.Select(i => i.GmailMessageId).Should().Equal("m2", "m3"); // no dupes, no skips
|
||||
page2.TotalCount.Should().Be(-1, "cursor windows skip the COUNT — that's the perf win");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user