From e0a08ade39dc3e0beacd4fe8a97a9bf0e6935efc Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Mar 2026 08:33:31 +0100 Subject: [PATCH 001/155] Add feature flag registry, domain model, database schema, and data migration --- .../20260310000100_SeedFeatureFlags.cs | 47 ++++++++ .../20260310000000_AddFeatureFlags.cs | 55 +++++++++ .../FeatureFlags/Domain/FeatureFlag.cs | 99 ++++++++++++++++ .../Domain/FeatureFlagConfiguration.cs | 13 +++ .../Domain/FeatureFlagRepository.cs | 48 ++++++++ .../Core/Features/Tenants/Domain/Tenant.cs | 4 + .../Core/Features/Users/Domain/User.cs | 4 + .../Authentication/GetUserSessionsTests.cs | 6 +- .../Tests/Authentication/SwitchTenantTests.cs | 30 +++-- .../GetDashboardMrrConsistencySummaryTests.cs | 3 +- .../GetUnsyncedSubscriptionsSummaryTests.cs | 3 +- .../Dashboard/GetDashboardKpisTests.cs | 6 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 3 +- .../GetDashboardPlanDistributionTests.cs | 3 +- .../GetDashboardRecentSignupsTests.cs | 3 +- .../GetDashboardRecentStripeEventsTests.cs | 3 +- .../Dashboard/GetDashboardTrendsTests.cs | 6 +- .../GetBackOfficeBillingEventsTests.cs | 3 +- .../CompleteEmailLoginTests.cs | 9 +- .../ResendEmailLoginCodeTests.cs | 3 +- .../StartEmailLoginTests.cs | 6 +- .../CompleteExternalLoginTests.cs | 21 ++-- .../ExternalAuthenticationTestBase.cs | 3 +- .../BackOffice/GetTenantDetailTests.cs | 9 +- .../BackOffice/GetTenantUserCountsTests.cs | 3 +- .../Tenants/BackOffice/GetTenantUsersTests.cs | 6 +- .../Tenants/BackOffice/GetTenantsTests.cs | 3 +- .../Tests/Tenants/GetTenantsForUserTests.cs | 24 ++-- .../GetBackOfficeUserDetailTests.cs | 6 +- .../GetBackOfficeUserSessionsTests.cs | 6 +- .../BackOffice/GetBackOfficeUsersTests.cs | 6 +- .../Tests/Users/BulkDeleteUsersTests.cs | 9 +- .../Tests/Users/BulkPurgeUsersTests.cs | 12 +- .../Tests/Users/DeclineInvitationTests.cs | 18 ++- .../account/Tests/Users/DeleteUserTests.cs | 9 +- .../Tests/Users/EmptyRecycleBinTests.cs | 6 +- .../Tests/Users/GetDeletedUsersTests.cs | 3 +- .../account/Tests/Users/GetUserByIdTests.cs | 3 +- .../Tests/Users/GetUserSummaryTests.cs | 9 +- .../account/Tests/Users/GetUsersTests.cs | 6 +- .../account/Tests/Users/InviteUserTests.cs | 3 +- .../account/Tests/Users/PurgeUserTests.cs | 6 +- .../account/Tests/Users/RestoreUserTests.cs | 6 +- .../FeatureFlags/FeatureFlagAdminLevel.cs | 8 ++ .../FeatureFlags/FeatureFlagDefinition.cs | 15 +++ .../FeatureFlags/FeatureFlagScope.cs | 8 ++ .../SharedKernel/FeatureFlags/FeatureFlags.cs | 110 ++++++++++++++++++ .../FeatureFlags/RolloutBucketHasher.cs | 22 ++++ 48 files changed, 609 insertions(+), 88 deletions(-) create mode 100644 application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs create mode 100644 application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs create mode 100644 application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs create mode 100644 application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs create mode 100644 application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAdminLevel.cs create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs diff --git a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs new file mode 100644 index 000000000..ddfdd8016 --- /dev/null +++ b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs @@ -0,0 +1,47 @@ +using Account.Features.FeatureFlags.Domain; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SharedKernel.Database; +using SharedKernel.FeatureFlags; + +namespace Account.Database.DataMigrations; + +public sealed class SeedFeatureFlags(AccountDbContext dbContext) : IDataMigration +{ + public string Id => "20260310000100_SeedFeatureFlags"; + + public TimeSpan Timeout { get; } = TimeSpan.FromMinutes(1); + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var flags = FeatureFlags.GetAll(); + var now = DateTimeOffset.UtcNow; + + foreach (var flag in flags) + { + var id = FeatureFlagId.NewId().Value; + + await dbContext.Database.ExecuteSqlRawAsync( + """ + INSERT INTO feature_flags (id, flag_key, tenant_id, user_id, created_at, modified_at, enabled_at, disabled_at, bucket_start, bucket_end, configurable_by_tenant, configurable_by_user) + VALUES (@id, @flagKey, NULL, NULL, @now, NULL, NULL, NULL, NULL, NULL, @configurableByTenant, @configurableByUser) + ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET + configurable_by_tenant = @configurableByTenant, + configurable_by_user = @configurableByUser + """, + [ + new NpgsqlParameter("@id", id), + new NpgsqlParameter("@flagKey", flag.Key), + new NpgsqlParameter("@now", now), + new NpgsqlParameter("@configurableByTenant", flag.ConfigurableByTenant), + new NpgsqlParameter("@configurableByUser", flag.ConfigurableByUser) + ], + cancellationToken + ); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return $"Upserted {flags.Length} feature flag base rows"; + } +} diff --git a/application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs b/application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs new file mode 100644 index 000000000..897386d19 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260310000000_AddFeatureFlags")] +public sealed class AddFeatureFlags : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "feature_flags", + table => new + { + tenant_id = table.Column("bigint", nullable: true), + id = table.Column("text", nullable: false), + flag_key = table.Column("text", nullable: false), + user_id = table.Column("text", nullable: true), + created_at = table.Column("timestamptz", nullable: false), + modified_at = table.Column("timestamptz", nullable: true), + enabled_at = table.Column("timestamptz", nullable: true), + disabled_at = table.Column("timestamptz", nullable: true), + bucket_start = table.Column("integer", nullable: true), + bucket_end = table.Column("integer", nullable: true), + configurable_by_tenant = table.Column("boolean", nullable: false, defaultValue: false), + configurable_by_user = table.Column("boolean", nullable: false, defaultValue: false) + }, + constraints: table => { table.PrimaryKey("pk_feature_flags", x => x.id); } + ); + + migrationBuilder.Sql( + "CREATE UNIQUE INDEX ix_feature_flags_flag_key_tenant_id_user_id ON feature_flags (flag_key, tenant_id, user_id) NULLS NOT DISTINCT" + ); + + migrationBuilder.Sql( + "ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_user_requires_tenant CHECK (user_id IS NULL OR tenant_id IS NOT NULL)" + ); + + migrationBuilder.Sql( + """ + ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_bucket_range + CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 1 AND 100 AND bucket_end BETWEEN 1 AND 100))) + """ + ); + + migrationBuilder.AddColumn("rollout_bucket", "tenants", "smallint", nullable: true); + migrationBuilder.Sql("UPDATE tenants SET rollout_bucket = floor(random() * 100 + 1)::smallint WHERE rollout_bucket IS NULL"); + migrationBuilder.Sql("ALTER TABLE tenants ALTER COLUMN rollout_bucket SET NOT NULL"); + + migrationBuilder.AddColumn("rollout_bucket", "users", "smallint", nullable: true); + migrationBuilder.Sql("UPDATE users SET rollout_bucket = floor(random() * 100 + 1)::smallint WHERE rollout_bucket IS NULL"); + migrationBuilder.Sql("ALTER TABLE users ALTER COLUMN rollout_bucket SET NOT NULL"); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs new file mode 100644 index 000000000..fa7a9a924 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -0,0 +1,99 @@ +using JetBrains.Annotations; +using SharedKernel.Domain; +using SharedKernel.StronglyTypedIds; + +namespace Account.Features.FeatureFlags.Domain; + +public sealed class FeatureFlag : AggregateRoot +{ + [UsedImplicitly] + private FeatureFlag() : base(FeatureFlagId.NewId()) + { + FlagKey = string.Empty; + } + + private FeatureFlag(string flagKey, long? tenantId, string? userId) + : base(FeatureFlagId.NewId()) + { + FlagKey = flagKey; + TenantId = tenantId; + UserId = userId; + } + + public string FlagKey { get; private set; } + + public long? TenantId { get; private set; } + + public string? UserId { get; private set; } + + public DateTimeOffset? EnabledAt { get; private set; } + + public DateTimeOffset? DisabledAt { get; private set; } + + public int? BucketStart { get; private set; } + + public int? BucketEnd { get; private set; } + + [UsedImplicitly] + public bool ConfigurableByTenant { get; private set; } + + [UsedImplicitly] + public bool ConfigurableByUser { get; private set; } + + public static FeatureFlag Create(string flagKey) + { + return new FeatureFlag(flagKey, null, null); + } + + public static FeatureFlag CreateTenantOverride(string flagKey, long tenantId) + { + return new FeatureFlag(flagKey, tenantId, null); + } + + public static FeatureFlag CreateUserOverride(string flagKey, long tenantId, string userId) + { + return new FeatureFlag(flagKey, tenantId, userId); + } + + public void Activate(DateTimeOffset now) + { + EnabledAt = now; + } + + public void Deactivate(DateTimeOffset now) + { + DisabledAt = now; + } + + public void SetRolloutRange(int? bucketStart, int? bucketEnd) + { + if (bucketStart is null != bucketEnd is null) + { + throw new ArgumentException("Bucket start and bucket end must both be set or both be null."); + } + + if (bucketStart is not null && (bucketStart < 1 || bucketStart > 100)) + { + throw new ArgumentOutOfRangeException(nameof(bucketStart), "Bucket start must be between 1 and 100."); + } + + if (bucketEnd is not null && (bucketEnd < 1 || bucketEnd > 100)) + { + throw new ArgumentOutOfRangeException(nameof(bucketEnd), "Bucket end must be between 1 and 100."); + } + + BucketStart = bucketStart; + BucketEnd = bucketEnd; + } +} + +[PublicAPI] +[IdPrefix("fflag")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record FeatureFlagId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs new file mode 100644 index 000000000..4d1d71c06 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SharedKernel.EntityFramework; + +namespace Account.Features.FeatureFlags.Domain; + +public sealed class FeatureFlagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.MapStronglyTypedUuid(f => f.Id); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs new file mode 100644 index 000000000..507c60472 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -0,0 +1,48 @@ +using Account.Database; +using Microsoft.EntityFrameworkCore; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.FeatureFlags.Domain; + +public interface IFeatureFlagRepository : ICrudRepository +{ + Task GetAllRelevantRowsAsync(long tenantId, string userId, CancellationToken cancellationToken); + + Task GetAllBaseRowsAsync(CancellationToken cancellationToken); + + Task GetTenantOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); + + Task GetByKeyAndScopeAsync(string flagKey, long? tenantId, string? userId, CancellationToken cancellationToken); +} + +internal sealed class FeatureFlagRepository(AccountDbContext accountDbContext) + : RepositoryBase(accountDbContext), IFeatureFlagRepository +{ + public async Task GetAllRelevantRowsAsync(long tenantId, string userId, CancellationToken cancellationToken) + { + return await DbSet + .Where(f => (f.TenantId == null || f.TenantId == tenantId) && (f.UserId == null || f.UserId == userId)) + .ToArrayAsync(cancellationToken); + } + + public async Task GetAllBaseRowsAsync(CancellationToken cancellationToken) + { + return await DbSet + .Where(f => f.TenantId == null && f.UserId == null) + .ToArrayAsync(cancellationToken); + } + + public async Task GetTenantOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken) + { + return await DbSet + .Where(f => f.FlagKey == flagKey && f.TenantId != null && f.UserId == null) + .ToArrayAsync(cancellationToken); + } + + public async Task GetByKeyAndScopeAsync(string flagKey, long? tenantId, string? userId, CancellationToken cancellationToken) + { + return await DbSet + .FirstOrDefaultAsync(f => f.FlagKey == flagKey && f.TenantId == tenantId && f.UserId == userId, cancellationToken); + } +} diff --git a/application/account/Core/Features/Tenants/Domain/Tenant.cs b/application/account/Core/Features/Tenants/Domain/Tenant.cs index 09aa46643..da7a4bd04 100644 --- a/application/account/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account/Core/Features/Tenants/Domain/Tenant.cs @@ -1,5 +1,6 @@ using Account.Features.Subscriptions.Domain; using SharedKernel.Domain; +using SharedKernel.FeatureFlags; namespace Account.Features.Tenants.Domain; @@ -10,6 +11,7 @@ private Tenant() : base(TenantId.NewId()) State = TenantState.Active; Plan = SubscriptionPlan.Basis; Logo = new Logo(); + RolloutBucket = RolloutBucketHasher.ComputeBucket(Id.Value.ToString()); } public string Name { get; private set; } = string.Empty; @@ -24,6 +26,8 @@ private Tenant() : base(TenantId.NewId()) public Logo Logo { get; private set; } + public int RolloutBucket { get; private set; } + public static Tenant Create(string email) { var tenant = new Tenant(); diff --git a/application/account/Core/Features/Users/Domain/User.cs b/application/account/Core/Features/Users/Domain/User.cs index 505de57b0..0152dbfd0 100644 --- a/application/account/Core/Features/Users/Domain/User.cs +++ b/application/account/Core/Features/Users/Domain/User.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Account.Features.ExternalAuthentication.Domain; using SharedKernel.Domain; +using SharedKernel.FeatureFlags; namespace Account.Features.Users.Domain; @@ -16,6 +17,7 @@ private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed Locale = locale ?? string.Empty; Avatar = new Avatar(); ExternalIdentities = []; + RolloutBucket = RolloutBucketHasher.ComputeBucket(Id.Value); } public string Email @@ -42,6 +44,8 @@ public string Email public ImmutableArray ExternalIdentities { get; private set; } + public int RolloutBucket { get; private set; } + public TenantId TenantId { get; } public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) diff --git a/application/account/Tests/Authentication/GetUserSessionsTests.cs b/application/account/Tests/Authentication/GetUserSessionsTests.cs index 5deba3589..20428a570 100644 --- a/application/account/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account/Tests/Authentication/GetUserSessionsTests.cs @@ -136,7 +136,8 @@ private long InsertTenant(string name) ("name", name), ("state", "Active"), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -160,7 +161,8 @@ private void InsertUser(long tenantId, UserId userId, string email) ("avatar", """{"Url":null,"Version":0,"IsGravatar":false}"""), ("role", "Owner"), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); } diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index b3e46989e..cb84b6230 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -31,7 +31,8 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("name", tenant2Name), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -50,7 +51,8 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -112,7 +114,8 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -129,7 +132,8 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Owner)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -173,7 +177,8 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("name", tenant2Name), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -192,7 +197,8 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -244,7 +250,8 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("name", tenant2Name), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -264,7 +271,8 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -323,7 +331,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -342,7 +351,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs index ad0185819..a27cefb81 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs @@ -115,7 +115,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs index be667ae57..2513c3844 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs @@ -65,7 +65,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index b7f87ed64..fafcea2d1 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -265,7 +265,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, DateTimeOffset c ("name", name), ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; @@ -392,7 +393,8 @@ private UserId SeedUser(TenantId tenantId, string email, DateTimeOffset createdA ("title", null), ("role", nameof(UserRole.Owner)), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); return userId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 706df7c0e..9e1f95f66 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -114,7 +114,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs index 105a12553..754a47b4f 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs @@ -89,7 +89,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan) ("name", name), ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs index 1bec665ce..d713b431c 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs @@ -82,7 +82,8 @@ private void SeedTenant(string name, DateTimeOffset createdAt) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs index 041eb1289..004757544 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs @@ -110,7 +110,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs index baec52c24..46af029ed 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs @@ -190,7 +190,8 @@ private TenantId SeedTenant(string name, DateTimeOffset createdAt) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; @@ -211,7 +212,8 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset createdAt) ("title", null), ("role", nameof(UserRole.Owner)), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs index 55e4336a8..f90a09f5b 100644 --- a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs +++ b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs @@ -251,7 +251,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index 78439eb49..f6bbafffa 100644 --- a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -219,7 +219,8 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -261,7 +262,8 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Owner)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -321,7 +323,8 @@ public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_Sh ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs b/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs index bf3939275..00fba3b73 100644 --- a/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs +++ b/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs @@ -75,7 +75,8 @@ public async Task ResendEmailLoginCode_WhenUserHasDanishLocale_ShouldSendDanishR ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "da-DK"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 50) ] ); var emailLoginId = await StartEmailLogin(email); diff --git a/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs index f1d8b3f33..c1209fc09 100644 --- a/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs @@ -74,7 +74,8 @@ public async Task StartEmailLogin_WhenUserHasDanishLocale_ShouldSendDanishLoginE ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "da-DK"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 50) ] ); var command = new StartEmailLoginCommand(email); @@ -227,7 +228,8 @@ public async Task StartEmailLogin_WhenUserIsSoftDeleted_ShouldReturnFakeEmailLog ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs index afa476d89..52911f9c3 100644 --- a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -217,7 +217,8 @@ public async Task CompleteExternalLogin_WhenUserHasNoExternalIdentity_ShouldLink ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -258,7 +259,8 @@ public async Task CompleteExternalLogin_WhenInvitedUserHasNoName_ShouldUpdateNam ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -300,7 +302,8 @@ public async Task CompleteExternalLogin_WhenUserAlreadyHasName_ShouldNotOverwrit ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -408,7 +411,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -451,7 +455,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", identities) + ("external_identities", identities), + ("rollout_bucket", 42) ] ); @@ -468,7 +473,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Owner)), ("locale", "en-US"), - ("external_identities", identities) + ("external_identities", identities), + ("rollout_bucket", 42) ] ); @@ -589,7 +595,8 @@ public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs index 5ea450586..25a8b10ed 100644 --- a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs +++ b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs @@ -234,7 +234,8 @@ protected UserId InsertUserWithExternalIdentity(string email, ExternalProviderTy ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", identities) + ("external_identities", identities), + ("rollout_bucket", 42) ] ); return userId; diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index 805903246..1d82eb1d5 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -29,7 +29,8 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("name", "Acme Corp"), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), - ("logo", """{"Url":"https://example.com/logo.png","Version":1}""") + ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), + ("rollout_bucket", 50) ] ); @@ -100,7 +101,8 @@ public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscr ("name", "No Subscription Inc"), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), - ("logo", """{"Url":null,"Version":1}""") + ("logo", """{"Url":null,"Version":1}"""), + ("rollout_bucket", 50) ] ); @@ -130,7 +132,8 @@ public async Task GetTenantDetail_WhenSubscriptionHasRefundedTransaction_ShouldE ("name", "Refunded Customer"), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), - ("logo", """{"Url":null,"Version":1}""") + ("logo", """{"Url":null,"Version":1}"""), + ("rollout_bucket", 50) ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs index 888f5b996..85880d40c 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs @@ -93,7 +93,8 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenA ("title", null), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs index ce67ea963..6a2d96f1b 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs @@ -30,7 +30,8 @@ public async Task GetTenantUsers_WhenCalled_ShouldReturnUsersForThatTenantOnly() ("name", "Other"), ("state", "Active"), ("plan", "Basis"), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); SeedUser(otherTenantId, "outsider@other.com", "Outsider", null, UserRole.Member); @@ -152,7 +153,8 @@ private void SeedUser(TenantId tenantId, string email, string? firstName, string ("title", null), ("role", role.ToString()), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index 887899294..d3edbe3c6 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -379,7 +379,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st ("name", name), ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); diff --git a/application/account/Tests/Tenants/GetTenantsForUserTests.cs b/application/account/Tests/Tenants/GetTenantsForUserTests.cs index 87fde3a27..9deec0ca0 100644 --- a/application/account/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account/Tests/Tenants/GetTenantsForUserTests.cs @@ -31,7 +31,8 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("name", tenant2Name), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -48,7 +49,8 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Owner)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -106,7 +108,8 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("name", "Other Tenant"), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -123,7 +126,8 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -152,7 +156,8 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("name", "Other User Tenant"), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -169,7 +174,8 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -199,7 +205,8 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("name", tenant2Name), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -216,7 +223,8 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs index d61292d1d..e875aac5c 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs @@ -126,7 +126,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); @@ -149,7 +150,8 @@ private UserId SeedUser(TenantId tenantId, string email, string? firstName, stri ("title", null), ("role", role.ToString()), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); return userId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs index b5dab0b76..26f012e85 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs @@ -125,7 +125,8 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); return tenantId; @@ -147,7 +148,8 @@ private UserId SeedUser(TenantId tenantId, string email, UserRole role) ("title", null), ("role", role.ToString()), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); return userId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index f6dac59ac..247726e23 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -286,7 +286,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan = SubscriptionPla ("name", name), ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 50) ] ); @@ -343,7 +344,8 @@ private void SeedUser(TenantId tenantId, string email, string? firstName, string ("title", null), ("role", role.ToString()), ("locale", "en-US"), - ("avatar", JsonSerializer.Serialize(new Avatar())) + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/Users/BulkDeleteUsersTests.cs b/application/account/Tests/Users/BulkDeleteUsersTests.cs index 1d7c74c1a..6de1e559d 100644 --- a/application/account/Tests/Users/BulkDeleteUsersTests.cs +++ b/application/account/Tests/Users/BulkDeleteUsersTests.cs @@ -38,7 +38,8 @@ public async Task BulkDeleteUsers_WhenUsersExist_ShouldSoftDeleteUsers() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); } @@ -149,7 +150,8 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -167,7 +169,8 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("email_confirmed", false), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/BulkPurgeUsersTests.cs b/application/account/Tests/Users/BulkPurgeUsersTests.cs index dc8176296..5e48b1bd7 100644 --- a/application/account/Tests/Users/BulkPurgeUsersTests.cs +++ b/application/account/Tests/Users/BulkPurgeUsersTests.cs @@ -35,7 +35,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -52,7 +53,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -69,7 +71,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -111,7 +114,8 @@ public async Task BulkPurgeUsers_WhenMember_ShouldReturnForbidden() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/DeclineInvitationTests.cs b/application/account/Tests/Users/DeclineInvitationTests.cs index f23ca9946..09166040c 100644 --- a/application/account/Tests/Users/DeclineInvitationTests.cs +++ b/application/account/Tests/Users/DeclineInvitationTests.cs @@ -30,7 +30,8 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -48,7 +49,8 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", ""), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -103,7 +105,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -114,7 +117,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("name", Faker.Company.CompanyName()), ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), - ("plan", nameof(SubscriptionPlan.Basis)) + ("plan", nameof(SubscriptionPlan.Basis)), + ("rollout_bucket", 42) ] ); @@ -132,7 +136,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", ""), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -150,7 +155,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("avatar", JsonSerializer.Serialize(new Avatar())), ("role", nameof(UserRole.Member)), ("locale", ""), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/DeleteUserTests.cs b/application/account/Tests/Users/DeleteUserTests.cs index 1aa7c57c4..1c28dd9ce 100644 --- a/application/account/Tests/Users/DeleteUserTests.cs +++ b/application/account/Tests/Users/DeleteUserTests.cs @@ -45,7 +45,8 @@ public async Task DeleteUser_WhenUserExists_ShouldSoftDeleteUser() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -91,7 +92,8 @@ public async Task DeleteUser_WhenUserHasEmailLoginHistory_ShouldSoftDeleteUserAn ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -140,7 +142,8 @@ public async Task DeleteUser_WhenUserNeverConfirmedEmail_ShouldSoftDeleteUser() ("email_confirmed", false), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/EmptyRecycleBinTests.cs b/application/account/Tests/Users/EmptyRecycleBinTests.cs index 53a9eb8aa..781e31e89 100644 --- a/application/account/Tests/Users/EmptyRecycleBinTests.cs +++ b/application/account/Tests/Users/EmptyRecycleBinTests.cs @@ -33,7 +33,8 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -50,7 +51,8 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/GetDeletedUsersTests.cs b/application/account/Tests/Users/GetDeletedUsersTests.cs index 7599281c5..21e7f3ec2 100644 --- a/application/account/Tests/Users/GetDeletedUsersTests.cs +++ b/application/account/Tests/Users/GetDeletedUsersTests.cs @@ -33,7 +33,8 @@ public async Task GetDeletedUsers_WhenOwner_ShouldReturnDeletedUsers() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/GetUserByIdTests.cs b/application/account/Tests/Users/GetUserByIdTests.cs index ce461487a..0024c63c7 100644 --- a/application/account/Tests/Users/GetUserByIdTests.cs +++ b/application/account/Tests/Users/GetUserByIdTests.cs @@ -30,7 +30,8 @@ public GetUserByIdTests() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); } diff --git a/application/account/Tests/Users/GetUserSummaryTests.cs b/application/account/Tests/Users/GetUserSummaryTests.cs index 8eed028b9..042e92cc1 100644 --- a/application/account/Tests/Users/GetUserSummaryTests.cs +++ b/application/account/Tests/Users/GetUserSummaryTests.cs @@ -37,7 +37,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("last_seen_at", now.AddDays(-5)), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -56,7 +57,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("last_seen_at", thirtyOneDaysAgo), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -75,7 +77,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("last_seen_at", null), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/GetUsersTests.cs b/application/account/Tests/Users/GetUsersTests.cs index 06e4f4a00..0a0ac5609 100644 --- a/application/account/Tests/Users/GetUsersTests.cs +++ b/application/account/Tests/Users/GetUsersTests.cs @@ -32,7 +32,8 @@ public GetUsersTests() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -48,7 +49,8 @@ public GetUsersTests() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); } diff --git a/application/account/Tests/Users/InviteUserTests.cs b/application/account/Tests/Users/InviteUserTests.cs index a759d44ff..834eaf2b1 100644 --- a/application/account/Tests/Users/InviteUserTests.cs +++ b/application/account/Tests/Users/InviteUserTests.cs @@ -177,7 +177,8 @@ public async Task InviteUser_WhenDeletedUserExists_ShouldReturnBadRequest() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/PurgeUserTests.cs b/application/account/Tests/Users/PurgeUserTests.cs index 881a30052..3761f7c23 100644 --- a/application/account/Tests/Users/PurgeUserTests.cs +++ b/application/account/Tests/Users/PurgeUserTests.cs @@ -31,7 +31,8 @@ public async Task PurgeUser_WhenOwnerDeletesSoftDeletedUser_ShouldSucceed() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -92,7 +93,8 @@ public async Task PurgeUser_WhenUserNotDeleted_ShouldReturnNotFound() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/RestoreUserTests.cs b/application/account/Tests/Users/RestoreUserTests.cs index 85d833f59..af9485568 100644 --- a/application/account/Tests/Users/RestoreUserTests.cs +++ b/application/account/Tests/Users/RestoreUserTests.cs @@ -31,7 +31,8 @@ public async Task RestoreUser_WhenOwnerRestoresDeletedUser_ShouldSucceed() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); @@ -92,7 +93,8 @@ public async Task RestoreUser_WhenUserNotDeleted_ShouldReturnNotFound() ("email_confirmed", true), ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), - ("external_identities", "[]") + ("external_identities", "[]"), + ("rollout_bucket", 42) ] ); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAdminLevel.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAdminLevel.cs new file mode 100644 index 000000000..4bff8646b --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAdminLevel.cs @@ -0,0 +1,8 @@ +namespace SharedKernel.FeatureFlags; + +public enum FeatureFlagAdminLevel +{ + SystemAdmin, + TenantOwner, + User +} diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs new file mode 100644 index 000000000..bc0b76898 --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs @@ -0,0 +1,15 @@ +namespace SharedKernel.FeatureFlags; + +[PublicAPI] +public sealed record FeatureFlagDefinition( + string Key, + FeatureFlagScope Scope, + FeatureFlagAdminLevel AdminLevel, + string Description, + string? ParentDependency = null, + bool IsAbTestEligible = false, + bool ConfigurableByTenant = false, + bool ConfigurableByUser = false, + bool TrackInTelemetry = false, + string? TelemetryName = null +); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs new file mode 100644 index 000000000..939374750 --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs @@ -0,0 +1,8 @@ +namespace SharedKernel.FeatureFlags; + +public enum FeatureFlagScope +{ + System, + Tenant, + User +} diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs new file mode 100644 index 000000000..6d93ed852 --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -0,0 +1,110 @@ +namespace SharedKernel.FeatureFlags; + +[PublicAPI] +public static class FeatureFlags +{ + public static readonly FeatureFlagDefinition BetaFeatures = new( + "beta-features", + FeatureFlagScope.Tenant, + FeatureFlagAdminLevel.SystemAdmin, + "Enables beta features for tenants", + IsAbTestEligible: true, + TrackInTelemetry: true + ); + + public static readonly FeatureFlagDefinition Sso = new( + "sso", + FeatureFlagScope.Tenant, + FeatureFlagAdminLevel.SystemAdmin, + "Enables single sign-on for tenants" + ); + + public static readonly FeatureFlagDefinition CustomBranding = new( + "custom-branding", + FeatureFlagScope.Tenant, + FeatureFlagAdminLevel.TenantOwner, + "Enables custom branding options for tenants", + ConfigurableByTenant: true + ); + + public static readonly FeatureFlagDefinition CompactView = new( + "compact-view", + FeatureFlagScope.User, + FeatureFlagAdminLevel.User, + "Enables compact view in the user interface", + ConfigurableByUser: true + ); + + private static readonly FeatureFlagDefinition[] AllFlags = [BetaFeatures, Sso, CustomBranding, CompactView]; + + static FeatureFlags() + { + ValidateFlags(); + } + + public static FeatureFlagDefinition[] GetAll() + { + return AllFlags; + } + + public static FeatureFlagDefinition? Get(string key) + { + return AllFlags.FirstOrDefault(f => f.Key == key); + } + + private static void ValidateFlags() + { + var flagsByKey = AllFlags.ToDictionary(f => f.Key); + + foreach (var flag in AllFlags) + { + if (flag.Key.Length > 50) + { + throw new InvalidOperationException($"Feature flag key '{flag.Key}' exceeds 50 characters."); + } + + if (flag.Key.Contains(',')) + { + throw new InvalidOperationException($"Feature flag key '{flag.Key}' must not contain commas."); + } + + switch (flag.Scope) + { + case FeatureFlagScope.System when flag.AdminLevel != FeatureFlagAdminLevel.SystemAdmin: + throw new InvalidOperationException($"Feature flag '{flag.Key}' with System scope must use SystemAdmin admin level."); + case FeatureFlagScope.Tenant when flag.AdminLevel is not (FeatureFlagAdminLevel.SystemAdmin or FeatureFlagAdminLevel.TenantOwner): + throw new InvalidOperationException($"Feature flag '{flag.Key}' with Tenant scope must use SystemAdmin or TenantOwner admin level."); + case FeatureFlagScope.User when flag.AdminLevel != FeatureFlagAdminLevel.User: + throw new InvalidOperationException($"Feature flag '{flag.Key}' with User scope must use User admin level."); + } + + if (flag.ConfigurableByTenant && (flag.Scope != FeatureFlagScope.Tenant || flag.AdminLevel != FeatureFlagAdminLevel.TenantOwner)) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' can only be ConfigurableByTenant when Scope=Tenant and AdminLevel=TenantOwner."); + } + + if (flag.ConfigurableByUser && (flag.Scope != FeatureFlagScope.User || flag.AdminLevel != FeatureFlagAdminLevel.User)) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' can only be ConfigurableByUser when Scope=User and AdminLevel=User."); + } + + if (flag is { ConfigurableByTenant: true, IsAbTestEligible: true }) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' cannot be both ConfigurableByTenant and IsAbTestEligible."); + } + + if (flag.ParentDependency is not null) + { + if (!flagsByKey.TryGetValue(flag.ParentDependency, out var parent)) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' references non-existent parent dependency '{flag.ParentDependency}'."); + } + + if (parent.ParentDependency is not null) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' has parent '{flag.ParentDependency}' which itself has a parent dependency. Only one level of dependency is allowed."); + } + } + } + } +} diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs new file mode 100644 index 000000000..d1666cb26 --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs @@ -0,0 +1,22 @@ +namespace SharedKernel.FeatureFlags; + +public static class RolloutBucketHasher +{ + private const uint FnvOffsetBasis = 2166136261; + private const uint FnvPrime = 16777619; + + public static int ComputeBucket(string entityId) + { + unchecked + { + var hash = FnvOffsetBasis; + foreach (var c in entityId) + { + hash ^= c; + hash *= FnvPrime; + } + + return (int)(hash % 100 + 1); + } + } +} From fba6605902de9c9441af135f6480a947c35df4a8 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Mar 2026 09:01:17 +0100 Subject: [PATCH 002/155] Add feature flag evaluation service, JWT claims, and telemetry integration --- application/account/Core/Configuration.cs | 2 + .../FeatureFlagEvaluationService.cs | 121 +++++++++ .../account/Core/Features/TelemetryEvents.cs | 21 ++ .../Core/Features/Users/Domain/User.cs | 3 + .../Features/Users/Shared/UserInfoFactory.cs | 11 +- .../FeatureFlagEvaluationServiceTests.cs | 251 ++++++++++++++++++ .../TokenGeneration/AccessTokenGenerator.cs | 5 +- .../SharedKernel/Authentication/UserInfo.cs | 38 ++- .../SharedKernel/Platform/Settings.cs | 7 + .../Platform/platform-settings.jsonc | 7 + ...ApplicationInsightsTelemetryInitializer.cs | 8 + .../Telemetry/OpenTelemetryEnricher.cs | 8 + .../shared-webapp/build/environment.d.ts | 12 + 13 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs create mode 100644 application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs diff --git a/application/account/Core/Configuration.cs b/application/account/Core/Configuration.cs index 611bbad97..2b2a3aa59 100644 --- a/application/account/Core/Configuration.cs +++ b/application/account/Core/Configuration.cs @@ -2,6 +2,7 @@ using Account.Features.EmailAuthentication.Shared; using Account.Features.ExternalAuthentication; using Account.Features.ExternalAuthentication.Shared; +using Account.Features.FeatureFlags; using Account.Features.Subscriptions.Shared; using Account.Features.Users.Shared; using Account.Integrations.Gravatar; @@ -70,6 +71,7 @@ public IServiceCollection AddAccountServices() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs b/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs new file mode 100644 index 000000000..9ef0ae72c --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs @@ -0,0 +1,121 @@ +using Account.Features.FeatureFlags.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags; + +public sealed class FeatureFlagEvaluationService(IFeatureFlagRepository featureFlagRepository) +{ + public async Task> EvaluateAsync(long tenantId, string userId, int tenantRolloutBucket, int? userRolloutBucket, CancellationToken cancellationToken) + { + var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, userId, cancellationToken); + var enabledFlags = new List(); + + var definitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll(); + + // Sort flags so parents are evaluated before children + var sorted = TopologicalSort(definitions); + + var enabledSet = new HashSet(); + + foreach (var definition in sorted) + { + if (definition.Scope == FeatureFlagScope.System) continue; + + var baseRow = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId is null && f.UserId is null); + if (baseRow is null) continue; + + if (!IsActive(baseRow)) continue; + + if (definition.ParentDependency is not null && !enabledSet.Contains(definition.ParentDependency)) continue; + + var isEnabled = definition.Scope switch + { + FeatureFlagScope.Tenant => EvaluateTenantScope(definition, baseRow, allRows, tenantId, tenantRolloutBucket), + FeatureFlagScope.User => EvaluateUserScope(definition, baseRow, allRows, tenantId, userId, userRolloutBucket), + _ => false + }; + + if (!isEnabled) continue; + + enabledSet.Add(definition.Key); + enabledFlags.Add(definition.Key); + } + + return enabledFlags; + } + + private static bool EvaluateTenantScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, long tenantId, int tenantRolloutBucket) + { + var tenantOverride = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId == tenantId && f.UserId is null); + if (tenantOverride is not null) + { + return IsActive(tenantOverride); + } + + if (definition.IsAbTestEligible && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) + { + return IsInBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + } + + return false; + } + + private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, long tenantId, string userId, int? userRolloutBucket) + { + if (string.IsNullOrEmpty(userId)) return false; + + var userOverride = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId == tenantId && f.UserId == userId); + if (userOverride is not null) + { + return IsActive(userOverride); + } + + if (definition.IsAbTestEligible && userRolloutBucket is not null && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) + { + return IsInBucketRange(userRolloutBucket.Value, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + } + + return false; + } + + private static bool IsActive(FeatureFlag flag) + { + return flag.EnabledAt is not null && (flag.DisabledAt is null || flag.EnabledAt > flag.DisabledAt); + } + + private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) + { + if (bucketStart <= bucketEnd) + { + return bucket >= bucketStart && bucket <= bucketEnd; + } + + // Wrap-around case (e.g., start=90, end=10 means 90-100 and 1-10) + return bucket >= bucketStart || bucket <= bucketEnd; + } + + private static FeatureFlagDefinition[] TopologicalSort(FeatureFlagDefinition[] definitions) + { + var result = new List(definitions.Length); + + // Add flags without parent dependencies first + foreach (var definition in definitions) + { + if (definition.ParentDependency is null) + { + result.Add(definition); + } + } + + // Then add flags with parent dependencies + foreach (var definition in definitions) + { + if (definition.ParentDependency is not null) + { + result.Add(definition); + } + } + + return result.ToArray(); + } +} diff --git a/application/account/Core/Features/TelemetryEvents.cs b/application/account/Core/Features/TelemetryEvents.cs index 5edfedd97..c83ee8733 100644 --- a/application/account/Core/Features/TelemetryEvents.cs +++ b/application/account/Core/Features/TelemetryEvents.cs @@ -64,6 +64,27 @@ public sealed class ExternalSignupFailed(ExternalLoginId? externalLoginId, Exter public sealed class ExternalSignupStarted(ExternalProviderType providerType) : TelemetryEvent(("provider_type", providerType)); +public sealed class FeatureFlagActivated(string flagKey) + : TelemetryEvent(("flag_key", flagKey)); + +public sealed class FeatureFlagDeactivated(string flagKey) + : TelemetryEvent(("flag_key", flagKey)); + +public sealed class FeatureFlagRolloutPercentageUpdated(string flagKey, int rolloutPercentage) + : TelemetryEvent(("flag_key", flagKey), ("rollout_percentage", rolloutPercentage)); + +public sealed class FeatureFlagTenantOverrideRemoved(string flagKey, string tenantId) + : TelemetryEvent(("flag_key", flagKey), ("tenant_id", tenantId)); + +public sealed class FeatureFlagTenantOverrideSet(string flagKey, string tenantId) + : TelemetryEvent(("flag_key", flagKey), ("tenant_id", tenantId)); + +public sealed class FeatureFlagUserOverrideRemoved(string flagKey, string userId) + : TelemetryEvent(("flag_key", flagKey), ("user_id", userId)); + +public sealed class FeatureFlagUserOverrideSet(string flagKey, string userId) + : TelemetryEvent(("flag_key", flagKey), ("user_id", userId)); + public sealed class GravatarUpdated(long size) : TelemetryEvent(("size", size)); diff --git a/application/account/Core/Features/Users/Domain/User.cs b/application/account/Core/Features/Users/Domain/User.cs index 0152dbfd0..ff1834a58 100644 --- a/application/account/Core/Features/Users/Domain/User.cs +++ b/application/account/Core/Features/Users/Domain/User.cs @@ -2,6 +2,7 @@ using Account.Features.ExternalAuthentication.Domain; using SharedKernel.Domain; using SharedKernel.FeatureFlags; +using SharedKernel.Platform; namespace Account.Features.Users.Domain; @@ -40,6 +41,8 @@ public string Email public string Locale { get; private set; } + public bool IsInternalUser => Email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase); + public DateTimeOffset? LastSeenAt { get; private set; } public ImmutableArray ExternalIdentities { get; private set; } diff --git a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs index b51ee3e91..dd10cbfef 100644 --- a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs @@ -1,3 +1,4 @@ +using Account.Features.FeatureFlags; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; using Account.Features.Users.Domain; @@ -11,7 +12,7 @@ namespace Account.Features.Users.Shared; /// Factory for creating UserInfo instances with tenant information. /// Centralizes the logic for creating UserInfo to follow SRP and avoid duplication. /// -public sealed class UserInfoFactory(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository) +public sealed class UserInfoFactory(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, FeatureFlagEvaluationService featureFlagEvaluationService) { /// /// Creates a UserInfo instance from a User entity, including tenant name. @@ -24,6 +25,8 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(user.TenantId, cancellationToken); + var enabledFlags = await featureFlagEvaluationService.EvaluateAsync(tenant.Id.Value, user.Id.Value, tenant.RolloutBucket, user.RolloutBucket, cancellationToken); + return new UserInfo { IsAuthenticated = true, @@ -39,7 +42,11 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se TenantName = tenant.Name, TenantLogoUrl = tenant.Logo.Url, SubscriptionPlan = subscription!.Plan.ToString(), - Locale = user.Locale + Locale = user.Locale, + IsInternalUser = user.IsInternalUser, + FeatureFlags = new HashSet(enabledFlags), + TenantRolloutBucket = tenant.RolloutBucket, + UserRolloutBucket = user.RolloutBucket }; } } diff --git a/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs new file mode 100644 index 000000000..1d84fcbcc --- /dev/null +++ b/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs @@ -0,0 +1,251 @@ +using Account.Database; +using Account.Features.FeatureFlags; +using Account.Features.FeatureFlags.Domain; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.FeatureFlags; + +public sealed class FeatureFlagEvaluationServiceTests : EndpointBaseTest +{ + private readonly FeatureFlagEvaluationService _evaluationService; + private readonly IServiceScope _scope; + + public FeatureFlagEvaluationServiceTests() + { + _scope = Provider.CreateScope(); + _evaluationService = _scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task Evaluate_WhenNoBaseRow_ShouldReturnEmpty() + { + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task Evaluate_WhenBaseRowActiveWithTenantOverride_ShouldReturnEnabled() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("sso", null, null, now, null, null, null); + InsertFeatureFlag("sso", DatabaseSeeder.Tenant1.Id.Value, null, now, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().Contain("sso"); + } + + [Fact] + public async Task Evaluate_WhenBaseRowInactive_ShouldReturnEmpty() + { + // Arrange + InsertFeatureFlag("sso", null, null, null, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().NotContain("sso"); + } + + [Fact] + public async Task Evaluate_WhenReactivated_EnabledAtAfterDisabledAt_ShouldReturnEnabled() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("sso", null, null, now, now.AddMinutes(-5), null, null); + InsertFeatureFlag("sso", DatabaseSeeder.Tenant1.Id.Value, null, now, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().Contain("sso"); + } + + [Fact] + public async Task Evaluate_WhenDisabledAtAfterEnabledAt_ShouldReturnEmpty() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("sso", null, null, now.AddMinutes(-10), now, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().NotContain("sso"); + } + + [Fact] + public async Task Evaluate_WhenAbTestEligibleAndBucketInRange_ShouldReturnEnabled() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("beta-features", null, null, now, null, 40, 60); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, null, CancellationToken.None); + + // Assert + result.Should().Contain("beta-features"); + } + + [Fact] + public async Task Evaluate_WhenAbTestEligibleAndBucketOutOfRange_ShouldReturnEmpty() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("beta-features", null, null, now, null, 40, 60); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 70, null, CancellationToken.None); + + // Assert + result.Should().NotContain("beta-features"); + } + + [Fact] + public async Task Evaluate_WhenBucketWrapAround_ShouldIncludeWrappedRange() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("beta-features", null, null, now, null, 90, 10); + + // Act + var resultInUpperRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 95, null, CancellationToken.None); + var resultInLowerRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 5, null, CancellationToken.None); + var resultOutOfRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, null, CancellationToken.None); + + // Assert + resultInUpperRange.Should().Contain("beta-features"); + resultInLowerRange.Should().Contain("beta-features"); + resultOutOfRange.Should().NotContain("beta-features"); + } + + [Fact] + public async Task Evaluate_WhenNullBucketStartAndEnd_ShouldNotEnableViaRollout() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("beta-features", null, null, now, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, null, CancellationToken.None); + + // Assert + result.Should().NotContain("beta-features"); + } + + [Fact] + public async Task Evaluate_WhenUserOverrideActive_ShouldReturnEnabled() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("compact-view", null, null, now, null, null, null); + InsertFeatureFlag("compact-view", DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, now, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().Contain("compact-view"); + } + + [Fact] + public async Task Evaluate_WhenUserIdEmpty_ShouldSkipUserScopedFlags() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("compact-view", null, null, now, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, "", 50, 50, CancellationToken.None); + + // Assert + result.Should().NotContain("compact-view"); + } + + [Fact] + public async Task Evaluate_WhenTenantOverrideDisabled_ShouldNotReturnFlag() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + InsertFeatureFlag("sso", null, null, now, null, null, null); + InsertFeatureFlag("sso", DatabaseSeeder.Tenant1.Id.Value, null, now.AddMinutes(-10), now, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().NotContain("sso"); + } + + [Fact] + public async Task Evaluate_WhenTenantOverrideExistsForDifferentTenant_ShouldNotReturnFlag() + { + // Arrange + var now = TimeProvider.System.GetUtcNow(); + var otherTenantId = TenantId.NewId().Value; + InsertFeatureFlag("sso", null, null, now, null, null, null); + InsertFeatureFlag("sso", otherTenantId, null, now, null, null, null); + + // Act + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + + // Assert + result.Should().NotContain("sso"); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _scope.Dispose(); + base.Dispose(disposing); + } + + private void InsertFeatureFlag(string flagKey, long? tenantId, string? userId, DateTimeOffset? enabledAt, DateTimeOffset? disabledAt, int? bucketStart, int? bucketEnd) + { + // Delete any seeded row with the same scope to avoid unique constraint conflicts + using var deleteCommand = new SqliteCommand( + tenantId is null && userId is null + ? "DELETE FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL" + : "DELETE FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + Connection + ); + deleteCommand.Parameters.AddWithValue("@flagKey", flagKey); + if (tenantId is not null) + { + deleteCommand.Parameters.AddWithValue("@tenantId", tenantId); + } + + deleteCommand.ExecuteNonQuery(); + + var id = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", id), + ("flag_key", flagKey), + ("tenant_id", tenantId), + ("user_id", userId), + ("created_at", TimeProvider.System.GetUtcNow()), + ("modified_at", null), + ("enabled_at", enabledAt), + ("disabled_at", disabledAt), + ("bucket_start", bucketStart), + ("bucket_end", bucketEnd), + ("configurable_by_tenant", false), + ("configurable_by_user", false) + ] + ); + } +} diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index 7d5d5596b..16bb96236 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -28,7 +28,10 @@ public string Generate(UserInfo userInfo) new Claim("title", userInfo.Title ?? string.Empty), new Claim("avatar_url", userInfo.AvatarUrl ?? string.Empty), new Claim("locale", userInfo.Locale!), - new Claim("session_id", userInfo.SessionId?.ToString() ?? string.Empty) + new Claim("session_id", userInfo.SessionId?.ToString() ?? string.Empty), + new Claim("feature_flags", string.Join(",", userInfo.FeatureFlags)), + new Claim("tenant_rollout_bucket", userInfo.TenantRolloutBucket.ToString()), + new Claim("user_rollout_bucket", userInfo.UserRolloutBucket?.ToString() ?? string.Empty) ] ) }; diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index f950de90a..da6a4eb19 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using SharedKernel.Authentication.TokenGeneration; using SharedKernel.Domain; +using SharedKernel.Platform; using SharedKernel.SinglePageApp; namespace SharedKernel.Authentication; @@ -13,6 +14,8 @@ public class UserInfo { private const string DefaultLocale = "en-US"; + private static readonly IReadOnlySet EmptyFeatureFlags = new HashSet(); + /// /// Represents the system user, typically used for background tasks or where no user is directly authenticated. /// @@ -54,6 +57,19 @@ public class UserInfo public SessionId? SessionId { get; init; } + public bool IsInternalUser { get; init; } + + public IReadOnlySet FeatureFlags { get; init; } = EmptyFeatureFlags; + + public int TenantRolloutBucket { get; init; } + + public int? UserRolloutBucket { get; init; } + + public bool IsFeatureFlagEnabled(string flagKey) + { + return FeatureFlags.Contains(flagKey); + } + public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, string? zoomLevel = null, string? theme = null) { if (user?.Identity?.IsAuthenticated != true) @@ -70,6 +86,10 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, stri var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var tenantId = user.FindFirstValue("tenant_id"); var sessionId = user.FindFirstValue("session_id"); + var email = user.FindFirstValue(ClaimTypes.Email); + var featureFlagsClaim = user.FindFirstValue("feature_flags"); + var tenantRolloutBucketClaim = user.FindFirstValue("tenant_rollout_bucket"); + var userRolloutBucketClaim = user.FindFirstValue("user_rollout_bucket"); return new UserInfo { IsAuthenticated = true, @@ -87,10 +107,26 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, stri SubscriptionPlan = user.FindFirstValue("subscription_plan"), Locale = GetValidLocale(user.FindFirstValue("locale")), ZoomLevel = zoomLevel, - Theme = theme + Theme = theme, + IsInternalUser = IsInternalUserEmail(email), + FeatureFlags = ParseFeatureFlags(featureFlagsClaim), + TenantRolloutBucket = !string.IsNullOrEmpty(tenantRolloutBucketClaim) ? int.Parse(tenantRolloutBucketClaim) : 0, + UserRolloutBucket = !string.IsNullOrEmpty(userRolloutBucketClaim) ? int.Parse(userRolloutBucketClaim) : null }; } + private static IReadOnlySet ParseFeatureFlags(string? claim) + { + if (string.IsNullOrEmpty(claim)) return EmptyFeatureFlags; + return new HashSet(claim.Split(',', StringSplitOptions.RemoveEmptyEntries)); + } + + private static bool IsInternalUserEmail(string? email) + { + if (string.IsNullOrEmpty(email)) return false; + return email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase); + } + private static string GetValidLocale(string? locale) { if (string.IsNullOrEmpty(locale)) diff --git a/application/shared-kernel/SharedKernel/Platform/Settings.cs b/application/shared-kernel/SharedKernel/Platform/Settings.cs index 2f80ac82b..16bb4bf97 100644 --- a/application/shared-kernel/SharedKernel/Platform/Settings.cs +++ b/application/shared-kernel/SharedKernel/Platform/Settings.cs @@ -8,6 +8,8 @@ public sealed class Settings public static Settings Current => Instance.Value; + public required IdentityConfig Identity { get; init; } + public required BrandingConfig Branding { get; init; } private static Settings LoadFromEmbeddedResource() @@ -28,6 +30,11 @@ private static Settings LoadFromEmbeddedResource() ?? throw new InvalidOperationException("Failed to deserialize platform settings"); } + public sealed class IdentityConfig + { + public required string InternalEmailDomain { get; init; } + } + public sealed class BrandingConfig { public required string ProductName { get; init; } diff --git a/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc b/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc index c2685748c..63357a6ad 100644 --- a/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc +++ b/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc @@ -8,6 +8,13 @@ // Security Note: Only include non-sensitive configuration here // Sensitive values (like API keys) should be stored in environment variables or key vaults + "identity": { + // Email domain suffix that identifies internal users + // Users with this domain get access to BackOffice and other internal features + // Currently used by backend only - frontend relies on isInternalUser flag from backend + "internalEmailDomain": "@platformplatform.net" + }, + "branding": { // Product/platform name used throughout the application // Placeholder for future use - currently hardcoded in various places diff --git a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs index 65788109f..3c6416cb7 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs @@ -68,6 +68,14 @@ public void Initialize(ITelemetry telemetry) AddCustomProperty(telemetry, "user.theme", executionContext.UserInfo.Theme); AddCustomProperty(telemetry, "user.role", executionContext.UserInfo.Role); AddCustomProperty(telemetry, "user.session_id", executionContext.UserInfo.SessionId?.Value); + + foreach (var flag in FeatureFlags.FeatureFlags.GetAll()) + { + if (!flag.TrackInTelemetry) continue; + var telemetryName = flag.TelemetryName ?? flag.Key; + var value = executionContext.UserInfo.FeatureFlags.Contains(flag.Key) ? "enabled" : "disabled"; + AddCustomProperty(telemetry, $"feature_{telemetryName}", value); + } } public static void SetContext(IExecutionContext executionContext) diff --git a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs index 9e7210811..f9994a0e6 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs @@ -34,5 +34,13 @@ public void Apply() Activity.Current.SetTag("user.role", executionContext.UserInfo.Role); Activity.Current.SetTag("user.session_id", executionContext.UserInfo.SessionId?.Value); + + foreach (var flag in FeatureFlags.FeatureFlags.GetAll()) + { + if (!flag.TrackInTelemetry) continue; + var telemetryName = flag.TelemetryName ?? flag.Key; + var value = executionContext.UserInfo.FeatureFlags.Contains(flag.Key) ? "enabled" : "disabled"; + Activity.Current.SetTag($"feature_{telemetryName}", value); + } } } diff --git a/application/shared-webapp/build/environment.d.ts b/application/shared-webapp/build/environment.d.ts index eac04dc51..5df028fe0 100644 --- a/application/shared-webapp/build/environment.d.ts +++ b/application/shared-webapp/build/environment.d.ts @@ -96,6 +96,18 @@ export declare global { * Tenant logo URL **/ tenantLogoUrl?: string | null; + /** + * Is internal user (has access to BackOffice) + **/ + isInternalUser?: boolean; + /** + * Tenant rollout bucket (1-100) for A/B testing + **/ + tenantRolloutBucket?: number; + /** + * User rollout bucket (1-100) for A/B testing + **/ + userRolloutBucket?: number | null; } /** From 61ba92f5ffe512f794bca3e8173dfcf3284d3b26 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Mar 2026 09:17:30 +0100 Subject: [PATCH 003/155] Add feature flag frontend registry, useFeatureFlag hook, and migrate env var checks --- .../account/WebApp/routes/login/index.tsx | 4 +- .../account/WebApp/routes/signup/index.tsx | 4 +- .../shared/components/AccountSideMenu.tsx | 4 +- .../shared-webapp/build/environment.d.ts | 4 + .../infrastructure/featureFlags/registry.ts | 78 +++++++++++++++++++ .../featureFlags/useFeatureFlag.ts | 22 ++++++ 6 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 application/shared-webapp/infrastructure/featureFlags/registry.ts create mode 100644 application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts diff --git a/application/account/WebApp/routes/login/index.tsx b/application/account/WebApp/routes/login/index.tsx index 4b5e97c53..2d87cdf81 100644 --- a/application/account/WebApp/routes/login/index.tsx +++ b/application/account/WebApp/routes/login/index.tsx @@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { signUpPath } from "@repo/infrastructure/auth/constants"; import { isValidReturnPath } from "@repo/infrastructure/auth/util"; +import { useFeatureFlag } from "@repo/infrastructure/featureFlags/useFeatureFlag"; import { Button } from "@repo/ui/components/Button"; import { Form } from "@repo/ui/components/Form"; import { Link } from "@repo/ui/components/Link"; @@ -58,6 +59,7 @@ export function LoginForm() { const { email: signupEmail } = getSignupState(); // Prefill from signup page if user navigated here const [email, setEmail] = useState(savedEmail || signupEmail || ""); const { returnPath } = Route.useSearch(); + const { enabled: isGoogleOAuthEnabled } = useFeatureFlag("google-oauth"); const startLoginMutation = api.useMutation("post", "/api/account/authentication/email/login/start"); const [isGoogleLoginPending, setIsGoogleLoginPending] = useState(false); @@ -132,7 +134,7 @@ export function LoginForm() { > {startLoginMutation.isPending ? Sending verification code... : Log in with email} - {import.meta.runtime_env.PUBLIC_GOOGLE_OAUTH_ENABLED === "true" && ( + {isGoogleOAuthEnabled && ( <>
diff --git a/application/account/WebApp/routes/signup/index.tsx b/application/account/WebApp/routes/signup/index.tsx index d23745800..ca88ba90d 100644 --- a/application/account/WebApp/routes/signup/index.tsx +++ b/application/account/WebApp/routes/signup/index.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { loginPath } from "@repo/infrastructure/auth/constants"; +import { useFeatureFlag } from "@repo/infrastructure/featureFlags/useFeatureFlag"; import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import { Button } from "@repo/ui/components/Button"; import { Field, FieldDescription, FieldLabel } from "@repo/ui/components/Field"; @@ -55,6 +56,7 @@ export function StartSignupForm() { const { email: savedEmail } = getSignupState(); const { email: loginEmail } = getLoginState(); // Prefill from login page if user navigated here const [email, setEmail] = useState(savedEmail || loginEmail || ""); + const { enabled: isGoogleOAuthEnabled } = useFeatureFlag("google-oauth"); const startSignupMutation = api.useMutation("post", "/api/account/authentication/email/signup/start"); const [isGoogleSignupPending, setIsGoogleSignupPending] = useState(false); @@ -147,7 +149,7 @@ export function StartSignupForm() { Sign up with email )} - {import.meta.runtime_env.PUBLIC_GOOGLE_OAUTH_ENABLED === "true" && ( + {isGoogleOAuthEnabled && ( <>
diff --git a/application/account/WebApp/shared/components/AccountSideMenu.tsx b/application/account/WebApp/shared/components/AccountSideMenu.tsx index ca6cd4f1a..f7d12655a 100644 --- a/application/account/WebApp/shared/components/AccountSideMenu.tsx +++ b/application/account/WebApp/shared/components/AccountSideMenu.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { useFeatureFlag } from "@repo/infrastructure/featureFlags/useFeatureFlag"; import { collapsedContext, Sidebar, @@ -43,13 +44,14 @@ export function AccountSideMenu() { const router = useRouter(); const currentPath = normalizePath(router.state.location.pathname); const { navigateToMain } = useMainNavigation(); + const { enabled: isSubscriptionEnabled } = useFeatureFlag("subscriptions"); const isActive = (target: string, matchPrefix = false) => { const normalized = normalizePath(target); return matchPrefix ? currentPath.startsWith(normalized) : currentPath === normalized; }; - const showBilling = userInfo?.role === "Owner" && import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; + const showBilling = userInfo?.role === "Owner" && isSubscriptionEnabled; return ( }> diff --git a/application/shared-webapp/build/environment.d.ts b/application/shared-webapp/build/environment.d.ts index 5df028fe0..5db908f4a 100644 --- a/application/shared-webapp/build/environment.d.ts +++ b/application/shared-webapp/build/environment.d.ts @@ -108,6 +108,10 @@ export declare global { * User rollout bucket (1-100) for A/B testing **/ userRolloutBucket?: number | null; + /** + * Enabled feature flag keys (database-scoped flags evaluated server-side at token issuance) + **/ + featureFlags?: string[]; } /** diff --git a/application/shared-webapp/infrastructure/featureFlags/registry.ts b/application/shared-webapp/infrastructure/featureFlags/registry.ts new file mode 100644 index 000000000..2cd95ea40 --- /dev/null +++ b/application/shared-webapp/infrastructure/featureFlags/registry.ts @@ -0,0 +1,78 @@ +type FeatureFlagScope = "system" | "tenant" | "user"; +type FeatureFlagAdminLevel = "systemAdmin" | "tenantOwner" | "user"; + +type BaseFeatureFlagDefinition = { + key: string; + scope: FeatureFlagScope; + adminLevel: FeatureFlagAdminLevel; + parentDependency: string | null; + description: string; +}; + +type SystemFeatureFlagDefinition = BaseFeatureFlagDefinition & { + scope: "system"; + envVar: string; +}; + +type DatabaseFeatureFlagDefinition = BaseFeatureFlagDefinition & { + scope: "tenant" | "user"; +}; + +export type FeatureFlagDefinition = SystemFeatureFlagDefinition | DatabaseFeatureFlagDefinition; + +const featureFlagRegistry: Record = { + "beta-features": { + key: "beta-features", + scope: "tenant", + adminLevel: "systemAdmin", + parentDependency: null, + description: "Enables beta features for tenants" + }, + sso: { + key: "sso", + scope: "tenant", + adminLevel: "systemAdmin", + parentDependency: null, + description: "Enables single sign-on for tenants" + }, + "custom-branding": { + key: "custom-branding", + scope: "tenant", + adminLevel: "tenantOwner", + parentDependency: null, + description: "Enables custom branding options for tenants" + }, + "compact-view": { + key: "compact-view", + scope: "user", + adminLevel: "user", + parentDependency: null, + description: "Enables compact view in the user interface" + }, + "google-oauth": { + key: "google-oauth", + scope: "system", + adminLevel: "systemAdmin", + parentDependency: null, + description: "Enables Google OAuth authentication", + envVar: "PUBLIC_GOOGLE_OAUTH_ENABLED" + }, + subscriptions: { + key: "subscriptions", + scope: "system", + adminLevel: "systemAdmin", + parentDependency: null, + description: "Enables subscription and billing features", + envVar: "PUBLIC_SUBSCRIPTION_ENABLED" + } +}; + +export function getFlag(key: string): FeatureFlagDefinition | undefined { + return featureFlagRegistry[key]; +} + +export function getAllFlags(): FeatureFlagDefinition[] { + return Object.values(featureFlagRegistry); +} + +export { featureFlagRegistry }; diff --git a/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts b/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts new file mode 100644 index 000000000..78637831e --- /dev/null +++ b/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts @@ -0,0 +1,22 @@ +import { getFlag } from "./registry"; + +type FeatureFlagResult = { enabled: boolean; isLoading: boolean }; + +const DISABLED: FeatureFlagResult = { enabled: false, isLoading: false }; +const ENABLED: FeatureFlagResult = { enabled: true, isLoading: false }; + +export function useFeatureFlag(flagKey: string): FeatureFlagResult { + const definition = getFlag(flagKey); + if (!definition) return DISABLED; + + if (definition.scope === "system") { + const envVar = definition.envVar as keyof RuntimeEnv; + return import.meta.runtime_env[envVar] === "true" ? ENABLED : DISABLED; + } + + const userInfo = import.meta.user_info_env; + if (!userInfo.isAuthenticated) return DISABLED; + + const enabledFlags = userInfo.featureFlags ?? []; + return enabledFlags.includes(flagKey) ? ENABLED : DISABLED; +} From 11b94bf4f7843893b4824d5085c55d40cbb2469b Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Mar 2026 09:37:05 +0100 Subject: [PATCH 004/155] Add feature flag management API endpoints, CQRS commands, and tests --- .../Api/Endpoints/FeatureFlagEndpoints.cs | 47 ++ .../20260310000100_SeedFeatureFlags.cs | 6 +- .../Commands/ActivateFeatureFlag.cs | 37 ++ .../Commands/DeactivateFeatureFlag.cs | 37 ++ .../SetFeatureFlagRolloutPercentage.cs | 67 +++ .../Commands/SetTenantFeatureFlagInternal.cs | 69 +++ .../Commands/SetTenantFeatureFlagOwner.cs | 83 +++ .../Commands/SetUserFeatureFlag.cs | 78 +++ .../FeatureFlags/Queries/GetFeatureFlags.cs | 95 ++++ application/account/Tests/DatabaseSeeder.cs | 22 + .../Tests/FeatureFlags/FeatureFlagTests.cs | 509 ++++++++++++++++++ .../SharedKernel/FeatureFlags/FeatureFlags.cs | 16 +- 12 files changed, 1064 insertions(+), 2 deletions(-) create mode 100644 application/account/Api/Endpoints/FeatureFlagEndpoints.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs create mode 100644 application/account/Tests/FeatureFlags/FeatureFlagTests.cs diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs new file mode 100644 index 000000000..8e35bf4c1 --- /dev/null +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -0,0 +1,47 @@ +using Account.Features.FeatureFlags.Commands; +using Account.Features.FeatureFlags.Queries; +using SharedKernel.ApiResults; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.Endpoints; + +public sealed class FeatureFlagEndpoints : IEndpoints +{ + public void MapEndpoints(IEndpointRouteBuilder routes) + { + // Internal API endpoints (back-office operations, no auth group) + var internalGroup = routes.MapGroup("/internal-api/account/feature-flags").WithGroupName(OpenApiDocumentNames.Account); + + internalGroup.MapGet("/", async Task> (IMediator mediator) + => await mediator.Send(new GetFeatureFlagsQuery()) + ).Produces(); + + internalGroup.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) + => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) + ); + + internalGroup.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) + => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) + ); + + internalGroup.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ); + + internalGroup.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ); + + // Authenticated API endpoints (tenant owner and user operations) + var group = routes.MapGroup("/api/account/feature-flags").WithTags("FeatureFlags").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem(); + + group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagOwnerCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ); + + group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ); + } +} diff --git a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs index ddfdd8016..d5ae0e5d5 100644 --- a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs +++ b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs @@ -17,8 +17,12 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var flags = FeatureFlags.GetAll(); var now = DateTimeOffset.UtcNow; + var seededCount = 0; foreach (var flag in flags) { + if (flag.Scope == FeatureFlagScope.System) continue; + + seededCount++; var id = FeatureFlagId.NewId().Value; await dbContext.Database.ExecuteSqlRawAsync( @@ -42,6 +46,6 @@ ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET await dbContext.SaveChangesAsync(cancellationToken); - return $"Upserted {flags.Length} feature flag base rows"; + return $"Upserted {seededCount} feature flag base rows"; } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs new file mode 100644 index 000000000..8a7afccf6 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs @@ -0,0 +1,37 @@ +using Account.Features.FeatureFlags.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record ActivateFeatureFlagCommand(string FlagKey) : ICommand, IRequest; + +public sealed class ActivateFeatureFlagValidator : AbstractValidator +{ + public ActivateFeatureFlagValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry."); + } +} + +public sealed class ActivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(ActivateFeatureFlagCommand command, CancellationToken cancellationToken) + { + var flag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (flag is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + + flag.Activate(timeProvider.GetUtcNow()); + featureFlagRepository.Update(flag); + + events.CollectEvent(new FeatureFlagActivated(command.FlagKey)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs new file mode 100644 index 000000000..002274e29 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs @@ -0,0 +1,37 @@ +using Account.Features.FeatureFlags.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record DeactivateFeatureFlagCommand(string FlagKey) : ICommand, IRequest; + +public sealed class DeactivateFeatureFlagValidator : AbstractValidator +{ + public DeactivateFeatureFlagValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry."); + } +} + +public sealed class DeactivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(DeactivateFeatureFlagCommand command, CancellationToken cancellationToken) + { + var flag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (flag is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + + flag.Deactivate(timeProvider.GetUtcNow()); + featureFlagRepository.Update(flag); + + events.CollectEvent(new FeatureFlagDeactivated(command.FlagKey)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs new file mode 100644 index 000000000..1513db243 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs @@ -0,0 +1,67 @@ +using Account.Features.FeatureFlags.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record SetFeatureFlagRolloutPercentageCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes this property from the API contract + public string FlagKey { get; init; } = null!; + + public required int RolloutPercentage { get; init; } +} + +public sealed class SetFeatureFlagRolloutPercentageValidator : AbstractValidator +{ + public SetFeatureFlagRolloutPercentageValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.IsAbTestEligible == true).WithMessage("Flag must be eligible for A/B testing."); + + RuleFor(x => x.RolloutPercentage) + .InclusiveBetween(0, 100).WithMessage("Rollout percentage must be between 0 and 100."); + } +} + +public sealed class SetFeatureFlagRolloutPercentageHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, CancellationToken cancellationToken) + { + var flag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (flag is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + + int? bucketStart; + int? bucketEnd; + + if (command.RolloutPercentage == 0) + { + bucketStart = null; + bucketEnd = null; + } + else if (command.RolloutPercentage == 100) + { + bucketStart = 1; + bucketEnd = 100; + } + else + { + bucketStart = RolloutBucketHasher.ComputeBucket(command.FlagKey); + bucketEnd = (bucketStart - 1 + command.RolloutPercentage) % 100 + 1; + } + + flag.SetRolloutRange(bucketStart, bucketEnd); + featureFlagRepository.Update(flag); + + events.CollectEvent(new FeatureFlagRolloutPercentageUpdated(command.FlagKey, command.RolloutPercentage)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs new file mode 100644 index 000000000..7c103188c --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -0,0 +1,69 @@ +using Account.Features.FeatureFlags.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record SetTenantFeatureFlagInternalCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes this property from the API contract + public string FlagKey { get; init; } = null!; + + public required long TenantId { get; init; } + + public required bool Enabled { get; init; } +} + +public sealed class SetTenantFeatureFlagInternalValidator : AbstractValidator +{ + public SetTenantFeatureFlagInternalValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + } +} + +public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(SetTenantFeatureFlagInternalCommand command, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + if (command.Enabled) + { + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + if (tenantOverride is null) + { + tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId); + tenantOverride.Activate(now); + await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); + } + else + { + tenantOverride.Activate(now); + featureFlagRepository.Update(tenantOverride); + } + + events.CollectEvent(new FeatureFlagTenantOverrideSet(command.FlagKey, command.TenantId.ToString())); + } + else + { + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + if (tenantOverride is not null) + { + tenantOverride.Deactivate(now); + featureFlagRepository.Update(tenantOverride); + events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); + } + } + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs new file mode 100644 index 000000000..0e0b952ef --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs @@ -0,0 +1,83 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.ExecutionContext; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record SetTenantFeatureFlagOwnerCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes this property from the API contract + public string FlagKey { get; init; } = null!; + + public required bool Enabled { get; init; } +} + +public sealed class SetTenantFeatureFlagOwnerValidator : AbstractValidator +{ + public SetTenantFeatureFlagOwnerValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + } +} + +public sealed class SetTenantFeatureFlagOwnerHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(SetTenantFeatureFlagOwnerCommand command, CancellationToken cancellationToken) + { + if (executionContext.UserInfo.Role != nameof(UserRole.Owner)) + { + return Result.Forbidden("Only owners are allowed to configure tenant feature flags."); + } + + var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(command.FlagKey); + if (definition is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + + if (definition.AdminLevel != FeatureFlagAdminLevel.TenantOwner || !definition.ConfigurableByTenant) + { + return Result.Forbidden($"Feature flag '{command.FlagKey}' is not configurable by tenant owners."); + } + + var tenantId = executionContext.TenantId!.Value; + var now = timeProvider.GetUtcNow(); + + if (command.Enabled) + { + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, tenantId, null, cancellationToken); + if (tenantOverride is null) + { + tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, tenantId); + tenantOverride.Activate(now); + await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); + } + else + { + tenantOverride.Activate(now); + featureFlagRepository.Update(tenantOverride); + } + + events.CollectEvent(new FeatureFlagTenantOverrideSet(command.FlagKey, tenantId.ToString())); + } + else + { + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, tenantId, null, cancellationToken); + if (tenantOverride is not null) + { + tenantOverride.Deactivate(now); + featureFlagRepository.Update(tenantOverride); + events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, tenantId.ToString())); + } + } + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs new file mode 100644 index 000000000..bc9b20461 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs @@ -0,0 +1,78 @@ +using Account.Features.FeatureFlags.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.ExecutionContext; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record SetUserFeatureFlagCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes this property from the API contract + public string FlagKey { get; init; } = null!; + + public required bool Enabled { get; init; } +} + +public sealed class SetUserFeatureFlagValidator : AbstractValidator +{ + public SetUserFeatureFlagValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + } +} + +public sealed class SetUserFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(SetUserFeatureFlagCommand command, CancellationToken cancellationToken) + { + var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(command.FlagKey); + if (definition is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + + if (definition.AdminLevel != FeatureFlagAdminLevel.User || !definition.ConfigurableByUser) + { + return Result.Forbidden($"Feature flag '{command.FlagKey}' is not configurable by users."); + } + + var tenantId = executionContext.TenantId!.Value; + var userId = executionContext.UserInfo.Id!.ToString(); + var now = timeProvider.GetUtcNow(); + + if (command.Enabled) + { + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, tenantId, userId, cancellationToken); + if (userOverride is null) + { + userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, tenantId, userId); + userOverride.Activate(now); + await featureFlagRepository.AddAsync(userOverride, cancellationToken); + } + else + { + userOverride.Activate(now); + featureFlagRepository.Update(userOverride); + } + + events.CollectEvent(new FeatureFlagUserOverrideSet(command.FlagKey, userId)); + } + else + { + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, tenantId, userId, cancellationToken); + if (userOverride is not null) + { + userOverride.Deactivate(now); + featureFlagRepository.Update(userOverride); + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, userId)); + } + } + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs new file mode 100644 index 000000000..44c07b7d5 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs @@ -0,0 +1,95 @@ +using Account.Features.FeatureFlags.Domain; +using JetBrains.Annotations; +using Microsoft.Extensions.Configuration; +using SharedKernel.Cqrs; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetFeatureFlagsQuery : IRequest>; + +[PublicAPI] +public sealed record GetFeatureFlagsResponse(FeatureFlagInfo[] Flags); + +[PublicAPI] +public sealed record FeatureFlagInfo( + string Key, + FeatureFlagScope Scope, + FeatureFlagAdminLevel AdminLevel, + string Description, + bool IsAbTestEligible, + bool ConfigurableByTenant, + bool ConfigurableByUser, + DateTimeOffset? EnabledAt, + DateTimeOffset? DisabledAt, + int? BucketStart, + int? BucketEnd, + int? RolloutPercentage, + bool IsActive +); + +public sealed class GetFeatureFlagsHandler(IFeatureFlagRepository featureFlagRepository, IConfiguration configuration) + : IRequestHandler> +{ + public async Task> Handle(GetFeatureFlagsQuery request, CancellationToken cancellationToken) + { + var definitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll(); + var baseRows = await featureFlagRepository.GetAllBaseRowsAsync(cancellationToken); + var baseRowsByKey = baseRows.ToDictionary(f => f.FlagKey); + + var flags = definitions.Select(definition => + { + if (definition.Scope == FeatureFlagScope.System) + { + var isSystemFlagActive = IsSystemFlagEnabled(definition.Key); + return new FeatureFlagInfo( + definition.Key, definition.Scope, definition.AdminLevel, definition.Description, + definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, + null, null, null, null, null, isSystemFlagActive + ); + } + + baseRowsByKey.TryGetValue(definition.Key, out var baseRow); + + var enabledAt = baseRow?.EnabledAt; + var disabledAt = baseRow?.DisabledAt; + var bucketStart = baseRow?.BucketStart; + var bucketEnd = baseRow?.BucketEnd; + var isActive = enabledAt is not null && (disabledAt is null || enabledAt > disabledAt); + var rolloutPercentage = ComputeRolloutPercentage(bucketStart, bucketEnd); + + return new FeatureFlagInfo( + definition.Key, definition.Scope, definition.AdminLevel, definition.Description, + definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, + enabledAt, disabledAt, bucketStart, bucketEnd, rolloutPercentage, isActive + ); + } + ).ToArray(); + + return new GetFeatureFlagsResponse(flags); + } + + private bool IsSystemFlagEnabled(string flagKey) + { + return flagKey switch + { + "google-oauth" => !string.IsNullOrEmpty(configuration["OAuth:Google:ClientId"]), + "subscriptions" => configuration["Stripe:SubscriptionEnabled"] == "true", + _ => false + }; + } + + private static int? ComputeRolloutPercentage(int? bucketStart, int? bucketEnd) + { + if (bucketStart is null || bucketEnd is null) return null; + + if (bucketStart <= bucketEnd) + { + return bucketEnd.Value - bucketStart.Value + 1; + } + + // Wrap-around case + return 100 - bucketStart.Value + 1 + bucketEnd.Value; + } +} diff --git a/application/account/Tests/DatabaseSeeder.cs b/application/account/Tests/DatabaseSeeder.cs index af922c599..f14750117 100644 --- a/application/account/Tests/DatabaseSeeder.cs +++ b/application/account/Tests/DatabaseSeeder.cs @@ -1,6 +1,7 @@ using System.Net; using Account.Database; using Account.Features.Authentication.Domain; +using Account.Features.FeatureFlags.Domain; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; using Account.Features.Users.Domain; @@ -9,6 +10,10 @@ namespace Account.Tests; public sealed class DatabaseSeeder { + public readonly FeatureFlag BetaFeaturesFlag; + public readonly FeatureFlag CompactViewFlag; + public readonly FeatureFlag CustomBrandingFlag; + public readonly FeatureFlag SsoFlag; public readonly Tenant Tenant1; public readonly User Tenant1Member; public readonly Session Tenant1MemberSession; @@ -36,6 +41,23 @@ public DatabaseSeeder(AccountDbContext accountDbContext) Tenant1Subscription = Subscription.Create(Tenant1.Id); accountDbContext.Set().Add(Tenant1Subscription); + var now = DateTimeOffset.UtcNow; + + BetaFeaturesFlag = FeatureFlag.Create("beta-features"); + BetaFeaturesFlag.Activate(now); + accountDbContext.Set().Add(BetaFeaturesFlag); + + SsoFlag = FeatureFlag.Create("sso"); + accountDbContext.Set().Add(SsoFlag); + + CustomBrandingFlag = FeatureFlag.Create("custom-branding"); + CustomBrandingFlag.Activate(now); + accountDbContext.Set().Add(CustomBrandingFlag); + + CompactViewFlag = FeatureFlag.Create("compact-view"); + CompactViewFlag.Activate(now); + accountDbContext.Set().Add(CompactViewFlag); + accountDbContext.SaveChanges(); } } diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs new file mode 100644 index 000000000..7eacd01c7 --- /dev/null +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -0,0 +1,509 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Database; +using Account.Features.FeatureFlags.Commands; +using Account.Features.FeatureFlags.Queries; +using FluentAssertions; +using SharedKernel.FeatureFlags; +using SharedKernel.Tests; +using SharedKernel.Tests.Persistence; +using Xunit; +using FeatureFlagRegistry = SharedKernel.FeatureFlags.FeatureFlags; +using FeatureFlagScope = SharedKernel.FeatureFlags.FeatureFlagScope; + +namespace Account.Tests.FeatureFlags; + +public sealed class FeatureFlagTests : EndpointBaseTest +{ + // Activation tests + + [Fact] + public async Task ActivateFeatureFlag_WhenValid_ShouldSetEnabledAt() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + enabledAt.Should().NotBeNullOrEmpty(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task ActivateFeatureFlag_WhenAlreadyActive_ShouldUpdateEnabledAt() + { + // Arrange + var flagKey = "beta-features"; + var originalEnabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedEnabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + updatedEnabledAt.Should().NotBe(originalEnabledAt); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenActive_ShouldSetDisabledAt() + { + // Arrange + var flagKey = "beta-features"; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + disabledAt.Should().NotBeNullOrEmpty(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagDeactivated"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenAlreadyInactive_ShouldHandleGracefully() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagDeactivated"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task ActivateFeatureFlag_AfterDeactivation_ShouldReactivateFlag() + { + // Arrange + var flagKey = "beta-features"; + await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + enabledAt.Should().NotBeNullOrEmpty(); + disabledAt.Should().NotBeNullOrEmpty(); + string.Compare(enabledAt, disabledAt, StringComparison.Ordinal).Should().BeGreaterThan(0, "EnabledAt should be after DisabledAt"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + // Tenant override tests (internal API) + + [Fact] + public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId }] + ); + rowCount.Should().Be(1); + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId }] + ); + enabledAt.Should().NotBeNullOrEmpty(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideSet"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_ShouldSucceed() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; + + // Act + var response = await AnonymousHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideSet"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + // Tenant override tests (owner API) + + [Fact] + public async Task SetTenantFeatureFlagOwner_WhenOwnerForConfigurableFlag_ShouldSucceed() + { + // Arrange + var flagKey = "custom-branding"; + var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideSet"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task SetTenantFeatureFlagOwner_WhenOwnerForNonConfigurableFlag_ShouldReturnForbidden() + { + // Arrange + var flagKey = "sso"; + var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, $"Feature flag '{flagKey}' is not configurable by tenant owners."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task SetTenantFeatureFlagOwner_WhenOwnerForAdminOnlyFlag_ShouldReturnForbidden() + { + // Arrange + var flagKey = "beta-features"; + var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, $"Feature flag '{flagKey}' is not configurable by tenant owners."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task SetTenantFeatureFlagOwner_WhenMember_ShouldReturnForbidden() + { + // Arrange + var flagKey = "custom-branding"; + var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; + + // Act + var response = await AuthenticatedMemberHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "Only owners are allowed to configure tenant feature flags."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + // User override tests + + [Fact] + public async Task SetUserFeatureFlag_WhenUserConfigurable_ShouldCreateOverrideRow() + { + // Arrange + var flagKey = "compact-view"; + var command = new SetUserFeatureFlagCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/user-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagUserOverrideSet"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task SetUserFeatureFlag_WhenNotUserScoped_ShouldFailValidation() + { + // Arrange + var flagKey = "sso"; + var command = new SetUserFeatureFlagCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/user-override", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + // Rollout percentage tests + + [Fact] + public async Task SetFeatureFlagRolloutPercentage_WhenValidPercentage_ShouldUpdateBucketRange() + { + // Arrange + var flagKey = "beta-features"; + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var bucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var bucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + bucketStart.Should().NotBeNull(); + bucketEnd.Should().NotBeNull(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.rollout_percentage"].Should().Be("50"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task SetFeatureFlagRolloutPercentage_WhenInvalidPercentage_ShouldFailValidation() + { + // Arrange + var flagKey = "beta-features"; + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 101 }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task SetFeatureFlagRolloutPercentage_WhenNonAbTestEligible_ShouldFailValidation() + { + // Arrange + var flagKey = "sso"; + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task SetFeatureFlagRolloutPercentage_WhenZeroPercent_ShouldClearBucketRange() + { + // Arrange + var flagKey = "beta-features"; + var setCommand = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", setCommand); + TelemetryEventsCollectorSpy.Reset(); + var clearCommand = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 0 }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", clearCommand); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var bucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var bucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + bucketStart.Should().BeNull(); + bucketEnd.Should().BeNull(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFullRange() + { + // Arrange + var flagKey = "beta-features"; + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 100 }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var bucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var bucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + bucketStart.Should().Be(1); + bucketEnd.Should().Be(100); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + // Query tests + + [Fact] + public async Task GetFeatureFlags_WhenCalled_ShouldReturnAllFlagsWithDatabaseState() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Flags.Should().HaveCount(FeatureFlagRegistry.GetAll().Length); + + var googleOauth = result.Flags.Single(f => f.Key == "google-oauth"); + googleOauth.Scope.Should().Be(FeatureFlagScope.System); + googleOauth.EnabledAt.Should().BeNull(); + + var subscriptions = result.Flags.Single(f => f.Key == "subscriptions"); + subscriptions.Scope.Should().Be(FeatureFlagScope.System); + subscriptions.EnabledAt.Should().BeNull(); + + var betaFeatures = result.Flags.Single(f => f.Key == "beta-features"); + betaFeatures.Scope.Should().Be(FeatureFlagScope.Tenant); + betaFeatures.IsAbTestEligible.Should().BeTrue(); + betaFeatures.IsActive.Should().BeTrue(); + betaFeatures.EnabledAt.Should().NotBeNull(); + + var sso = result.Flags.Single(f => f.Key == "sso"); + sso.IsActive.Should().BeFalse(); + sso.EnabledAt.Should().BeNull(); + + var customBranding = result.Flags.Single(f => f.Key == "custom-branding"); + customBranding.ConfigurableByTenant.Should().BeTrue(); + customBranding.IsActive.Should().BeTrue(); + + var compactView = result.Flags.Single(f => f.Key == "compact-view"); + compactView.ConfigurableByUser.Should().BeTrue(); + compactView.IsActive.Should().BeTrue(); + } + + // A/B consistency tests + + [Fact] + public void BucketRange_WhenNormalRange_ShouldMatchCorrectly() + { + // Arrange & Act & Assert + IsInBucketRange(50, 40, 60).Should().BeTrue(); + IsInBucketRange(39, 40, 60).Should().BeFalse(); + IsInBucketRange(61, 40, 60).Should().BeFalse(); + IsInBucketRange(40, 40, 60).Should().BeTrue(); + IsInBucketRange(60, 40, 60).Should().BeTrue(); + } + + [Fact] + public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() + { + // Arrange & Act & Assert + IsInBucketRange(95, 90, 10).Should().BeTrue(); + IsInBucketRange(5, 90, 10).Should().BeTrue(); + IsInBucketRange(50, 90, 10).Should().BeFalse(); + IsInBucketRange(90, 90, 10).Should().BeTrue(); + IsInBucketRange(10, 90, 10).Should().BeTrue(); + IsInBucketRange(11, 90, 10).Should().BeFalse(); + IsInBucketRange(89, 90, 10).Should().BeFalse(); + } + + [Fact] + public void RolloutBucket_ShouldBeDeterministic() + { + // Arrange + var entityId = "test-entity-123"; + + // Act + var bucket1 = RolloutBucketHasher.ComputeBucket(entityId); + var bucket2 = RolloutBucketHasher.ComputeBucket(entityId); + + // Assert + bucket1.Should().Be(bucket2); + bucket1.Should().BeInRange(1, 100); + } + + private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) + { + if (bucketStart <= bucketEnd) + { + return bucket >= bucketStart && bucket <= bucketEnd; + } + + return bucket >= bucketStart || bucket <= bucketEnd; + } +} diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs index 6d93ed852..00cdfc946 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -3,6 +3,20 @@ namespace SharedKernel.FeatureFlags; [PublicAPI] public static class FeatureFlags { + public static readonly FeatureFlagDefinition GoogleOauth = new( + "google-oauth", + FeatureFlagScope.System, + FeatureFlagAdminLevel.SystemAdmin, + "Google OAuth authentication" + ); + + public static readonly FeatureFlagDefinition Subscriptions = new( + "subscriptions", + FeatureFlagScope.System, + FeatureFlagAdminLevel.SystemAdmin, + "Subscription billing via Stripe" + ); + public static readonly FeatureFlagDefinition BetaFeatures = new( "beta-features", FeatureFlagScope.Tenant, @@ -35,7 +49,7 @@ public static class FeatureFlags ConfigurableByUser: true ); - private static readonly FeatureFlagDefinition[] AllFlags = [BetaFeatures, Sso, CustomBranding, CompactView]; + private static readonly FeatureFlagDefinition[] AllFlags = [GoogleOauth, Subscriptions, BetaFeatures, Sso, CustomBranding, CompactView]; static FeatureFlags() { From ab472639c2f0e52aca6d37de731060c033ae42ea Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 15:43:00 +0200 Subject: [PATCH 005/155] Add JWT invalidation when feature flags change via FeatureFlagVersion tracking --- .../FeatureFlagVersionMiddleware.cs | 36 +++++++++++++++++++ application/account/Api/Program.cs | 6 +++- ...02000000_AddFeatureFlagVersionToTenants.cs | 14 ++++++++ .../Commands/ActivateFeatureFlag.cs | 5 ++- .../Commands/DeactivateFeatureFlag.cs | 5 ++- .../SetFeatureFlagRolloutPercentage.cs | 5 ++- .../Commands/SetTenantFeatureFlagInternal.cs | 11 +++++- .../Commands/SetTenantFeatureFlagOwner.cs | 7 +++- .../Commands/SetUserFeatureFlag.cs | 7 +++- .../Core/Features/Tenants/Domain/Tenant.cs | 7 ++++ .../Tenants/Domain/TenantRepository.cs | 14 ++++++++ .../Features/Users/Shared/UserInfoFactory.cs | 1 + .../Authentication/GetUserSessionsTests.cs | 3 +- .../Tests/Authentication/SwitchTenantTests.cs | 15 +++++--- .../GetDashboardMrrConsistencySummaryTests.cs | 3 +- .../GetUnsyncedSubscriptionsSummaryTests.cs | 3 +- .../Dashboard/GetDashboardKpisTests.cs | 3 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 3 +- .../GetDashboardPlanDistributionTests.cs | 3 +- .../GetDashboardRecentSignupsTests.cs | 3 +- .../GetDashboardRecentStripeEventsTests.cs | 3 +- .../Dashboard/GetDashboardTrendsTests.cs | 3 +- .../GetBackOfficeBillingEventsTests.cs | 3 +- .../CompleteEmailLoginTests.cs | 6 ++-- .../CompleteExternalLoginTests.cs | 6 ++-- .../BackOffice/GetTenantDetailTests.cs | 9 +++-- .../Tenants/BackOffice/GetTenantUsersTests.cs | 3 +- .../Tenants/BackOffice/GetTenantsTests.cs | 3 +- .../Tests/Tenants/GetTenantsForUserTests.cs | 12 ++++--- .../GetBackOfficeUserDetailTests.cs | 3 +- .../GetBackOfficeUserSessionsTests.cs | 3 +- .../BackOffice/GetBackOfficeUsersTests.cs | 3 +- .../Tests/Users/DeclineInvitationTests.cs | 9 +++-- .../TokenGeneration/AccessTokenGenerator.cs | 1 + .../SharedKernel/Authentication/UserInfo.cs | 4 +++ 35 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs create mode 100644 application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs diff --git a/application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs b/application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs new file mode 100644 index 000000000..0ff607410 --- /dev/null +++ b/application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs @@ -0,0 +1,36 @@ +using Account.Features.Tenants.Domain; +using SharedKernel.Authentication; +using SharedKernel.Domain; + +namespace Account.Api.Middleware; + +public sealed class FeatureFlagVersionMiddleware(ITenantRepository tenantRepository) : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (context.User.Identity?.IsAuthenticated == true) + { + var tenantIdClaim = context.User.FindFirst("tenant_id")?.Value; + var versionClaim = context.User.FindFirst("feature_flag_version")?.Value; + + if (tenantIdClaim is not null && long.TryParse(tenantIdClaim, out var tenantIdValue) && + versionClaim is not null && int.TryParse(versionClaim, out var jwtVersion)) + { + var tenantId = new TenantId(tenantIdValue); + var currentVersion = await tenantRepository.GetFeatureFlagVersionAsync(tenantId, context.RequestAborted); + + if (jwtVersion != currentVersion) + { + context.Response.OnStarting(() => + { + context.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + } + } + } + + await next(context); + } +} diff --git a/application/account/Api/Program.cs b/application/account/Api/Program.cs index 677333590..2ffd6a013 100644 --- a/application/account/Api/Program.cs +++ b/application/account/Api/Program.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Account; using Account.Api; +using Account.Api.Middleware; using Microsoft.Extensions.Options; using SharedKernel.Authentication; using SharedKernel.Authentication.BackOfficeIdentity; @@ -22,7 +23,8 @@ builder.Services .AddApiServices([Assembly.GetExecutingAssembly(), Configuration.Assembly], ApiDocumentLayout.AccountAndBackOffice) .AddAccountServices() - .AddBackOfficeDevStaticProxy(); + .AddBackOfficeDevStaticProxy() + .AddScoped(); var app = builder.Build(); @@ -139,6 +141,8 @@ ); } +app.UseMiddleware(); + await app.RunAsync(); return; diff --git a/application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs b/application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs new file mode 100644 index 000000000..5f536bcbe --- /dev/null +++ b/application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260402000000_AddFeatureFlagVersionToTenants")] +public sealed class AddFeatureFlagVersionToTenants : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("feature_flag_version", "tenants", "integer", nullable: false, defaultValue: 0); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs index 8a7afccf6..0aa9ce8b4 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -19,7 +20,7 @@ public ActivateFeatureFlagValidator() } } -public sealed class ActivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class ActivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(ActivateFeatureFlagCommand command, CancellationToken cancellationToken) @@ -30,6 +31,8 @@ public async Task Handle(ActivateFeatureFlagCommand command, Cancellatio flag.Activate(timeProvider.GetUtcNow()); featureFlagRepository.Update(flag); + await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); + events.CollectEvent(new FeatureFlagActivated(command.FlagKey)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs index 002274e29..235a8a7ac 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -19,7 +20,7 @@ public DeactivateFeatureFlagValidator() } } -public sealed class DeactivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class DeactivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(DeactivateFeatureFlagCommand command, CancellationToken cancellationToken) @@ -30,6 +31,8 @@ public async Task Handle(DeactivateFeatureFlagCommand command, Cancellat flag.Deactivate(timeProvider.GetUtcNow()); featureFlagRepository.Update(flag); + await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); + events.CollectEvent(new FeatureFlagDeactivated(command.FlagKey)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs index 1513db243..68da98125 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -30,7 +31,7 @@ public SetFeatureFlagRolloutPercentageValidator() } } -public sealed class SetFeatureFlagRolloutPercentageHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) +public sealed class SetFeatureFlagRolloutPercentageHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, CancellationToken cancellationToken) @@ -60,6 +61,8 @@ public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, flag.SetRolloutRange(bucketStart, bucketEnd); featureFlagRepository.Update(flag); + await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); + events.CollectEvent(new FeatureFlagRolloutPercentageUpdated(command.FlagKey, command.RolloutPercentage)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 7c103188c..532d21c79 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -1,7 +1,9 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; +using SharedKernel.Domain; using SharedKernel.FeatureFlags; using SharedKernel.Telemetry; @@ -29,7 +31,7 @@ public SetTenantFeatureFlagInternalValidator() } } -public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetTenantFeatureFlagInternalCommand command, CancellationToken cancellationToken) @@ -64,6 +66,13 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca } } + var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + if (tenant is not null) + { + tenant.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + } + return Result.Success(); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs index 0e0b952ef..90999487f 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; @@ -29,7 +30,7 @@ public SetTenantFeatureFlagOwnerValidator() } } -public sealed class SetTenantFeatureFlagOwnerHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetTenantFeatureFlagOwnerHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetTenantFeatureFlagOwnerCommand command, CancellationToken cancellationToken) @@ -78,6 +79,10 @@ public async Task Handle(SetTenantFeatureFlagOwnerCommand command, Cance } } + var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); + tenant!.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + return Result.Success(); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs index bc9b20461..9c3f4ae0c 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -28,7 +29,7 @@ public SetUserFeatureFlagValidator() } } -public sealed class SetUserFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetUserFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetUserFeatureFlagCommand command, CancellationToken cancellationToken) @@ -73,6 +74,10 @@ public async Task Handle(SetUserFeatureFlagCommand command, Cancellation } } + var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); + tenant!.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + return Result.Success(); } } diff --git a/application/account/Core/Features/Tenants/Domain/Tenant.cs b/application/account/Core/Features/Tenants/Domain/Tenant.cs index da7a4bd04..f93856393 100644 --- a/application/account/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account/Core/Features/Tenants/Domain/Tenant.cs @@ -28,6 +28,8 @@ private Tenant() : base(TenantId.NewId()) public int RolloutBucket { get; private set; } + public int FeatureFlagVersion { get; private set; } + public static Tenant Create(string email) { var tenant = new Tenant(); @@ -68,6 +70,11 @@ public void UpdatePlan(SubscriptionPlan plan) { Plan = plan; } + + public void IncrementFeatureFlagVersion() + { + FeatureFlagVersion++; + } } public sealed record Logo(string? Url = null, int Version = 0); diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 2aa097c5b..7aa88a6fe 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -55,6 +55,10 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// Used by the back-office dashboard "Recent signups" list. ///
Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken); + + Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken); + + Task IncrementAllFeatureFlagVersionsAsync(CancellationToken cancellationToken); } public sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -159,4 +163,14 @@ public async Task GetMostRecentSignupsUnfilteredAsync(int limit, Cance var tenants = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); return tenants.OrderByDescending(t => t.CreatedAt).Take(limit).ToArray(); } + + public async Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken) + { + return await DbSet.Where(t => t.Id == tenantId).Select(t => t.FeatureFlagVersion).SingleOrDefaultAsync(cancellationToken); + } + + public async Task IncrementAllFeatureFlagVersionsAsync(CancellationToken cancellationToken) + { + await accountDbContext.Database.ExecuteSqlRawAsync("UPDATE tenants SET feature_flag_version = feature_flag_version + 1", cancellationToken); + } } diff --git a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs index dd10cbfef..cc4e8587a 100644 --- a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs @@ -45,6 +45,7 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se Locale = user.Locale, IsInternalUser = user.IsInternalUser, FeatureFlags = new HashSet(enabledFlags), + FeatureFlagVersion = tenant.FeatureFlagVersion, TenantRolloutBucket = tenant.RolloutBucket, UserRolloutBucket = user.RolloutBucket }; diff --git a/application/account/Tests/Authentication/GetUserSessionsTests.cs b/application/account/Tests/Authentication/GetUserSessionsTests.cs index 20428a570..916d3cdd1 100644 --- a/application/account/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account/Tests/Authentication/GetUserSessionsTests.cs @@ -137,7 +137,8 @@ private long InsertTenant(string name) ("state", "Active"), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index cb84b6230..1ee1b21d2 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -32,7 +32,8 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -115,7 +116,8 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -178,7 +180,8 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -251,7 +254,8 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -332,7 +336,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs index a27cefb81..db17bb011 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs @@ -116,7 +116,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs index 2513c3844..01590d91d 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs @@ -66,7 +66,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index fafcea2d1..43511e5c1 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -266,7 +266,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, DateTimeOffset c ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 9e1f95f66..40aacd324 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -115,7 +115,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs index 754a47b4f..3516cc353 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs @@ -90,7 +90,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan) ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs index d713b431c..4eaf136dd 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs @@ -83,7 +83,8 @@ private void SeedTenant(string name, DateTimeOffset createdAt) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); } diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs index 004757544..c7b0395a1 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs @@ -111,7 +111,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs index 46af029ed..92d6e4835 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs @@ -191,7 +191,8 @@ private TenantId SeedTenant(string name, DateTimeOffset createdAt) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs index f90a09f5b..7ea11ab5c 100644 --- a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs +++ b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs @@ -252,7 +252,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index f6bbafffa..0db2344ab 100644 --- a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -220,7 +220,8 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -324,7 +325,8 @@ public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_Sh ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs index 52911f9c3..1e84414fe 100644 --- a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -412,7 +412,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -596,7 +597,8 @@ public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index 1d82eb1d5..ecaa32568 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -30,7 +30,8 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); @@ -102,7 +103,8 @@ public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscr ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":1}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); @@ -133,7 +135,8 @@ public async Task GetTenantDetail_WhenSubscriptionHasRefundedTransaction_ShouldE ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":1}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs index 6a2d96f1b..f6b7d99a4 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs @@ -31,7 +31,8 @@ public async Task GetTenantUsers_WhenCalled_ShouldReturnUsersForThatTenantOnly() ("state", "Active"), ("plan", "Basis"), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); SeedUser(otherTenantId, "outsider@other.com", "Outsider", null, UserRole.Member); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index d3edbe3c6..8495301a2 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -380,7 +380,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/GetTenantsForUserTests.cs b/application/account/Tests/Tenants/GetTenantsForUserTests.cs index 9deec0ca0..498ec1544 100644 --- a/application/account/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account/Tests/Tenants/GetTenantsForUserTests.cs @@ -32,7 +32,8 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -109,7 +110,8 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -157,7 +159,8 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -206,7 +209,8 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs index e875aac5c..d9b8f645a 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs @@ -127,7 +127,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs index 26f012e85..81966c546 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs @@ -126,7 +126,8 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index 247726e23..3751fb904 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -287,7 +287,8 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan = SubscriptionPla ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Users/DeclineInvitationTests.cs b/application/account/Tests/Users/DeclineInvitationTests.cs index 09166040c..7a7ebca18 100644 --- a/application/account/Tests/Users/DeclineInvitationTests.cs +++ b/application/account/Tests/Users/DeclineInvitationTests.cs @@ -31,7 +31,8 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -106,7 +107,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); @@ -118,7 +120,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("feature_flag_version", 0) ] ); diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index 16bb96236..2c45a9a58 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -30,6 +30,7 @@ public string Generate(UserInfo userInfo) new Claim("locale", userInfo.Locale!), new Claim("session_id", userInfo.SessionId?.ToString() ?? string.Empty), new Claim("feature_flags", string.Join(",", userInfo.FeatureFlags)), + new Claim("feature_flag_version", userInfo.FeatureFlagVersion.ToString()), new Claim("tenant_rollout_bucket", userInfo.TenantRolloutBucket.ToString()), new Claim("user_rollout_bucket", userInfo.UserRolloutBucket?.ToString() ?? string.Empty) ] diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index da6a4eb19..645be2b78 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -61,6 +61,8 @@ public class UserInfo public IReadOnlySet FeatureFlags { get; init; } = EmptyFeatureFlags; + public int FeatureFlagVersion { get; init; } + public int TenantRolloutBucket { get; init; } public int? UserRolloutBucket { get; init; } @@ -88,6 +90,7 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, stri var sessionId = user.FindFirstValue("session_id"); var email = user.FindFirstValue(ClaimTypes.Email); var featureFlagsClaim = user.FindFirstValue("feature_flags"); + var featureFlagVersionClaim = user.FindFirstValue("feature_flag_version"); var tenantRolloutBucketClaim = user.FindFirstValue("tenant_rollout_bucket"); var userRolloutBucketClaim = user.FindFirstValue("user_rollout_bucket"); return new UserInfo @@ -110,6 +113,7 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, stri Theme = theme, IsInternalUser = IsInternalUserEmail(email), FeatureFlags = ParseFeatureFlags(featureFlagsClaim), + FeatureFlagVersion = !string.IsNullOrEmpty(featureFlagVersionClaim) ? int.Parse(featureFlagVersionClaim) : 0, TenantRolloutBucket = !string.IsNullOrEmpty(tenantRolloutBucketClaim) ? int.Parse(tenantRolloutBucketClaim) : 0, UserRolloutBucket = !string.IsNullOrEmpty(userRolloutBucketClaim) ? int.Parse(userRolloutBucketClaim) : null }; From bbbe9900ac3b90b97d0dcd416d5b1193ee905430 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 15:43:29 +0200 Subject: [PATCH 006/155] Add feature flag tenants query and tenants endpoint to account SCS --- .../Api/Endpoints/FeatureFlagEndpoints.cs | 4 + .../account/Api/Endpoints/TenantEndpoints.cs | 4 + .../FeatureFlags/Queries/GetFlagTenants.cs | 79 ++++++ .../Tenants/Domain/TenantRepository.cs | 4 +- .../Features/Tenants/Queries/GetTenants.cs | 28 ++ .../Tests/FeatureFlags/FeatureFlagTests.cs | 247 ++++++++++++++++++ .../account/Tests/Tenants/GetTenantsTests.cs | 37 +++ 7 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs create mode 100644 application/account/Core/Features/Tenants/Queries/GetTenants.cs create mode 100644 application/account/Tests/Tenants/GetTenantsTests.cs diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 8e35bf4c1..c81dbd3f7 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -17,6 +17,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new GetFeatureFlagsQuery()) ).Produces(); + internalGroup.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) + => await mediator.Send(new GetFlagTenantsQuery { FlagKey = flagKey }) + ).Produces(); + internalGroup.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) ); diff --git a/application/account/Api/Endpoints/TenantEndpoints.cs b/application/account/Api/Endpoints/TenantEndpoints.cs index 8af2dd8b3..9448982e0 100644 --- a/application/account/Api/Endpoints/TenantEndpoints.cs +++ b/application/account/Api/Endpoints/TenantEndpoints.cs @@ -37,6 +37,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) // Internal-only endpoint reachable backend-to-backend via the cluster's localhost address. // BlockInternalApiTransform in AppGateway rejects external callers. + routes.MapGet("/internal-api/account/tenants", async Task> (IMediator mediator) + => await mediator.Send(new GetTenantsQuery()) + ).Produces().WithGroupName(OpenApiDocumentNames.Account); + routes.MapDelete("/internal-api/account/tenants/{id}", async Task (TenantId id, IMediator mediator) => await mediator.Send(new DeleteTenantCommand(id)) ).WithGroupName(OpenApiDocumentNames.Account); diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs new file mode 100644 index 000000000..d802de35b --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs @@ -0,0 +1,79 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetFlagTenantsQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public string FlagKey { get; init; } = null!; +} + +[PublicAPI] +public sealed record GetFlagTenantsResponse(FlagTenantInfo[] Tenants); + +[PublicAPI] +public sealed record FlagTenantInfo(long TenantId, string TenantName, bool IsEnabled, string Source); + +public sealed class GetFlagTenantsValidator : AbstractValidator +{ + public GetFlagTenantsValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + } +} + +public sealed class GetFlagTenantsHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetFlagTenantsQuery query, CancellationToken cancellationToken) + { + var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); + if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); + + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var tenantOverrides = await featureFlagRepository.GetTenantOverridesForFlagAsync(query.FlagKey, cancellationToken); + var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!.Value); + + var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(query.FlagKey, null, null, cancellationToken); + + var flagTenants = tenants.Select(tenant => + { + if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) + { + var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); + return new FlagTenantInfo(tenant.Id.Value, tenant.Name, isEnabled, "manual_override"); + } + + if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + var isInRange = IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return new FlagTenantInfo(tenant.Id.Value, tenant.Name, isInRange, "ab_rollout"); + } + + return new FlagTenantInfo(tenant.Id.Value, tenant.Name, false, "default"); + } + ).ToArray(); + + return new GetFlagTenantsResponse(flagTenants); + } + + private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) + { + if (bucketStart <= bucketEnd) + { + return bucket >= bucketStart && bucket <= bucketEnd; + } + + // Wrap-around case (e.g., start=90, end=10 means 90-100 and 1-10) + return bucket >= bucketStart || bucket <= bucketEnd; + } +} diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 7aa88a6fe..bd1d8cfd2 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -47,6 +47,7 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// /// Returns every tenant without applying tenant query filters. /// Used by the back-office dashboard KPI snapshot to count tenants by state and plan across all tenants. + /// Also used by internal API endpoints where tenant context is not established. /// Task GetAllUnfilteredAsync(CancellationToken cancellationToken); @@ -146,10 +147,11 @@ public async Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, /// /// Returns every tenant without applying tenant query filters. /// Used by the back-office dashboard KPI snapshot to count tenants by state and plan across all tenants. + /// Also used by internal API endpoints where tenant context is not established. /// public async Task GetAllUnfilteredAsync(CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).OrderBy(t => t.Id).ToArrayAsync(cancellationToken); } /// diff --git a/application/account/Core/Features/Tenants/Queries/GetTenants.cs b/application/account/Core/Features/Tenants/Queries/GetTenants.cs new file mode 100644 index 000000000..51700ca89 --- /dev/null +++ b/application/account/Core/Features/Tenants/Queries/GetTenants.cs @@ -0,0 +1,28 @@ +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.Queries; + +[PublicAPI] +public sealed record GetTenantsQuery : IRequest>; + +[PublicAPI] +public sealed record GetTenantsResponse(TenantSummary[] Tenants); + +[PublicAPI] +public sealed record TenantSummary(TenantId Id, string Name); + +public sealed class GetTenantsHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantsQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + + var tenantSummaries = tenants.Select(t => new TenantSummary(t.Id, t.Name)).ToArray(); + + return new GetTenantsResponse(tenantSummaries); + } +} diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 7eacd01c7..01831adbd 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using Account.Database; using Account.Features.FeatureFlags.Commands; +using Account.Features.FeatureFlags.Domain; using Account.Features.FeatureFlags.Queries; using FluentAssertions; using SharedKernel.FeatureFlags; @@ -415,6 +416,114 @@ public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFu TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } + // Flag tenants query tests + + [Fact] + public async Task GetFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWithDefaultSource() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotBeEmpty(); + result.Tenants.Should().AllSatisfy(t => + { + t.Source.Should().Be("default"); + t.IsEnabled.Should().BeFalse(); + } + ); + } + + [Fact] + public async Task GetFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrideSource() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var overrideId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId), + ("user_id", null), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false) + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + var tenantResult = result.Tenants.Single(t => t.TenantId == tenantId); + tenantResult.IsEnabled.Should().BeTrue(); + tenantResult.Source.Should().Be("manual_override"); + } + + [Fact] + public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource() + { + // Arrange + var flagKey = "beta-features"; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 1), + ("bucket_end", 100) + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().AllSatisfy(t => + { + t.Source.Should().Be("ab_rollout"); + t.IsEnabled.Should().BeTrue(); + } + ); + } + + [Fact] + public async Task GetFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/non-existent/tenants"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetFlagTenants_WhenSystemScopedFlag_ShouldReturnBadRequest() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/google-oauth/tenants"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + // Query tests [Fact] @@ -497,6 +606,144 @@ public void RolloutBucket_ShouldBeDeterministic() bucket1.Should().BeInRange(1, 100); } + // JWT invalidation tests + + [Fact] + public async Task ActivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task SetTenantFeatureFlagInternal_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + { + // Arrange + var flagKey = "custom-branding"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + { + // Arrange + var flagKey = "compact-view"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + var command = new SetUserFeatureFlagCommand { Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/user-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) { if (bucketStart <= bucketEnd) diff --git a/application/account/Tests/Tenants/GetTenantsTests.cs b/application/account/Tests/Tenants/GetTenantsTests.cs new file mode 100644 index 000000000..b01bf7199 --- /dev/null +++ b/application/account/Tests/Tenants/GetTenantsTests.cs @@ -0,0 +1,37 @@ +using Account.Database; +using Account.Features.Tenants.Queries; +using FluentAssertions; +using SharedKernel.Tests; +using Xunit; + +namespace Account.Tests.Tenants; + +public sealed class GetTenantsTests : EndpointBaseTest +{ + [Fact] + public async Task GetTenants_WhenCalled_ShouldReturnAllTenants() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotBeEmpty(); + result.Tenants.Should().Contain(t => t.Id == DatabaseSeeder.Tenant1.Id); + } + + [Fact] + public async Task GetTenants_WhenCalledWithoutAuth_ShouldSucceed() + { + // Act + var response = await AnonymousHttpClient.GetAsync("/internal-api/account/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotBeEmpty(); + } +} From e06a96c40d3548b998eca07caaf929dfb4cb999d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 15:44:59 +0200 Subject: [PATCH 007/155] Add feature flag toggles to account settings and user preferences --- .../Api/Endpoints/FeatureFlagEndpoints.cs | 8 ++ .../GetTenantConfigurableFeatureFlags.cs | 50 +++++++++++ .../GetUserConfigurableFeatureFlags.cs | 51 +++++++++++ .../settings/-components/FeaturesSection.tsx | 90 +++++++++++++++++++ .../WebApp/routes/account/settings/index.tsx | 2 + .../-components/BetaFeaturesSection.tsx | 87 ++++++++++++++++++ .../WebApp/routes/user/preferences/index.tsx | 3 + .../shared/lib/api/featureFlagLabels.ts | 33 +++++++ .../shared/translations/locale/da-DK.po | 27 ++++++ .../shared/translations/locale/en-US.po | 27 ++++++ 10 files changed, 378 insertions(+) create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs create mode 100644 application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx create mode 100644 application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx create mode 100644 application/account/WebApp/shared/lib/api/featureFlagLabels.ts diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index c81dbd3f7..86bd9e010 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -40,6 +40,14 @@ public void MapEndpoints(IEndpointRouteBuilder routes) // Authenticated API endpoints (tenant owner and user operations) var group = routes.MapGroup("/api/account/feature-flags").WithTags("FeatureFlags").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem(); + group.MapGet("/tenant-configurable", async Task> (IMediator mediator) + => await mediator.Send(new GetTenantConfigurableFeatureFlagsQuery()) + ).Produces(); + + group.MapGet("/user-configurable", async Task> (IMediator mediator) + => await mediator.Send(new GetUserConfigurableFeatureFlagsQuery()) + ).Produces(); + group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagOwnerCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) ); diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs new file mode 100644 index 000000000..861a9bf83 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs @@ -0,0 +1,50 @@ +using Account.Features.FeatureFlags.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.ExecutionContext; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetTenantConfigurableFeatureFlagsQuery : IRequest>; + +[PublicAPI] +public sealed record TenantConfigurableFeatureFlagsResponse(TenantConfigurableFeatureFlag[] Flags); + +[PublicAPI] +public sealed record TenantConfigurableFeatureFlag(string FlagKey, bool Enabled); + +public sealed class GetTenantConfigurableFeatureFlagsHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext) + : IRequestHandler> +{ + public async Task> Handle(GetTenantConfigurableFeatureFlagsQuery query, CancellationToken cancellationToken) + { + var tenantId = executionContext.TenantId!.Value; + + var configurableDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll() + .Where(f => f is { Scope: FeatureFlagScope.Tenant, ConfigurableByTenant: true }) + .ToArray(); + + var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, string.Empty, cancellationToken); + var baseRows = allRows.Where(r => r.TenantId is null && r.UserId is null).ToDictionary(r => r.FlagKey); + var tenantOverrides = allRows.Where(r => r.TenantId == tenantId && r.UserId is null).ToDictionary(r => r.FlagKey); + + var flags = configurableDefinitions + .Where(definition => baseRows.TryGetValue(definition.Key, out var baseRow) && IsActive(baseRow)) + .Select(definition => + { + tenantOverrides.TryGetValue(definition.Key, out var tenantOverride); + var enabled = tenantOverride is not null && IsActive(tenantOverride); + return new TenantConfigurableFeatureFlag(definition.Key, enabled); + } + ).ToArray(); + + return new TenantConfigurableFeatureFlagsResponse(flags); + } + + private static bool IsActive(FeatureFlag row) + { + return row.EnabledAt is not null && (row.DisabledAt is null || row.EnabledAt > row.DisabledAt); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs new file mode 100644 index 000000000..05873d4df --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs @@ -0,0 +1,51 @@ +using Account.Features.FeatureFlags.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.ExecutionContext; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetUserConfigurableFeatureFlagsQuery : IRequest>; + +[PublicAPI] +public sealed record UserConfigurableFeatureFlagsResponse(UserConfigurableFeatureFlag[] Flags); + +[PublicAPI] +public sealed record UserConfigurableFeatureFlag(string FlagKey, bool Enabled); + +public sealed class GetUserConfigurableFeatureFlagsHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext) + : IRequestHandler> +{ + public async Task> Handle(GetUserConfigurableFeatureFlagsQuery query, CancellationToken cancellationToken) + { + var tenantId = executionContext.TenantId!.Value; + var userId = executionContext.UserInfo.Id!; + + var configurableDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll() + .Where(f => f is { Scope: FeatureFlagScope.User, ConfigurableByUser: true }) + .ToArray(); + + var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, userId, cancellationToken); + var baseRows = allRows.Where(r => r.TenantId is null && r.UserId is null).ToDictionary(r => r.FlagKey); + var userOverrides = allRows.Where(r => r.TenantId == tenantId && r.UserId == userId).ToDictionary(r => r.FlagKey); + + var flags = configurableDefinitions + .Where(definition => baseRows.TryGetValue(definition.Key, out var baseRow) && IsActive(baseRow)) + .Select(definition => + { + userOverrides.TryGetValue(definition.Key, out var userOverride); + var enabled = userOverride is not null && IsActive(userOverride); + return new UserConfigurableFeatureFlag(definition.Key, enabled); + } + ).ToArray(); + + return new UserConfigurableFeatureFlagsResponse(flags); + } + + private static bool IsActive(FeatureFlag row) + { + return row.EnabledAt is not null && (row.DisabledAt is null || row.EnabledAt > row.DisabledAt); + } +} diff --git a/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx b/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx new file mode 100644 index 000000000..cb544c3ce --- /dev/null +++ b/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx @@ -0,0 +1,90 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Separator } from "@repo/ui/components/Separator"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Switch } from "@repo/ui/components/Switch"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { api } from "@/shared/lib/api/client"; +import { getFeatureFlagLabel } from "@/shared/lib/api/featureFlagLabels"; + +interface TenantFlag { + flagKey: string; + enabled: boolean; +} + +export function FeaturesSection() { + const { data, isLoading } = api.useQuery("get", "/api/account/feature-flags/tenant-configurable"); + + if (isLoading) { + return ; + } + + const tenantFlags = data?.flags ?? []; + if (tenantFlags.length === 0) { + return null; + } + + return ( +
+

+ Features +

+ +

+ Toggle features available to your account. +

+
+ {tenantFlags.map((flag) => ( + + ))} +
+
+ ); +} + +function TenantFlagToggle({ flagKey, enabled }: Readonly) { + const queryClient = useQueryClient(); + const label = getFeatureFlagLabel(flagKey); + + const toggleMutation = api.useMutation("put", "/api/account/feature-flags/{flagKey}/tenant-override", { + onSuccess: async () => { + toast.success(t`Feature updated`); + await queryClient.invalidateQueries({ queryKey: ["get", "/api/account/feature-flags/tenant-configurable"] }); + } + }); + + const handleToggle = (checked: boolean) => { + toggleMutation.mutate({ params: { path: { flagKey } }, body: { enabled: checked } }); + }; + + return ( +
+
+ {label.name} + {label.description} +
+ +
+ ); +} + +function FeaturesSkeleton() { + return ( +
+ + + +
+ + +
+
+ ); +} diff --git a/application/account/WebApp/routes/account/settings/index.tsx b/application/account/WebApp/routes/account/settings/index.tsx index 291b312fa..5b91b3178 100644 --- a/application/account/WebApp/routes/account/settings/index.tsx +++ b/application/account/WebApp/routes/account/settings/index.tsx @@ -20,6 +20,7 @@ import { api, type Schemas, UserRole } from "@/shared/lib/api/client"; import { AccountInfoFields } from "./-components/AccountInfoFields"; import DeleteAccountConfirmation from "./-components/DeleteAccountConfirmation"; +import { FeaturesSection } from "./-components/FeaturesSection"; export const Route = createFileRoute("/account/settings/")({ staticData: { trackingTitle: "Account settings" }, @@ -149,6 +150,7 @@ export function AccountSettings() { )} + {isOwner && } {isOwner && } diff --git a/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx b/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx new file mode 100644 index 000000000..7e274980e --- /dev/null +++ b/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx @@ -0,0 +1,87 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Switch } from "@repo/ui/components/Switch"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { api } from "@/shared/lib/api/client"; +import { getFeatureFlagLabel } from "@/shared/lib/api/featureFlagLabels"; + +interface UserFlag { + flagKey: string; + enabled: boolean; +} + +export function BetaFeaturesSection() { + const { data, isLoading } = api.useQuery("get", "/api/account/feature-flags/user-configurable"); + + if (isLoading) { + return ; + } + + const userFlags = data?.flags ?? []; + if (userFlags.length === 0) { + return null; + } + + return ( +
+

+ Beta features +

+

+ Opt in to try new features before they are available to everyone. +

+
+ {userFlags.map((flag) => ( + + ))} +
+
+ ); +} + +function UserFlagToggle({ flagKey, enabled }: Readonly) { + const queryClient = useQueryClient(); + const label = getFeatureFlagLabel(flagKey); + + const toggleMutation = api.useMutation("put", "/api/account/feature-flags/{flagKey}/user-override", { + onSuccess: async () => { + toast.success(t`Preference updated`); + await queryClient.invalidateQueries({ queryKey: ["get", "/api/account/feature-flags/user-configurable"] }); + } + }); + + const handleToggle = (checked: boolean) => { + toggleMutation.mutate({ params: { path: { flagKey } }, body: { enabled: checked } }); + }; + + return ( +
+
+ {label.name} + {label.description} +
+ +
+ ); +} + +function BetaFeaturesSkeleton() { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/application/account/WebApp/routes/user/preferences/index.tsx b/application/account/WebApp/routes/user/preferences/index.tsx index cc87f96fd..9ceeec34b 100644 --- a/application/account/WebApp/routes/user/preferences/index.tsx +++ b/application/account/WebApp/routes/user/preferences/index.tsx @@ -5,6 +5,7 @@ import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; import { createFileRoute } from "@tanstack/react-router"; import { MoonIcon, SunIcon } from "lucide-react"; +import { BetaFeaturesSection } from "./-components/BetaFeaturesSection"; import { ThemeMode, locales, usePreferences } from "./-components/usePreferences"; export const Route = createFileRoute("/user/preferences/")({ @@ -119,6 +120,8 @@ function PreferencesPage() { ))} + + ); diff --git a/application/account/WebApp/shared/lib/api/featureFlagLabels.ts b/application/account/WebApp/shared/lib/api/featureFlagLabels.ts new file mode 100644 index 000000000..b19430e99 --- /dev/null +++ b/application/account/WebApp/shared/lib/api/featureFlagLabels.ts @@ -0,0 +1,33 @@ +import { t } from "@lingui/core/macro"; + +interface FeatureFlagLabel { + name: string; + description: string; +} + +function getKnownFlagLabels(): Record { + return { + "custom-branding": { + name: t`Custom branding`, + description: t`Enable branded login page` + }, + "compact-view": { + name: t`Compact view`, + description: t`Use a more compact layout` + } + }; +} + +function formatFlagKey(flagKey: string): string { + const formatted = flagKey.replace(/-/g, " "); + return formatted.charAt(0).toUpperCase() + formatted.slice(1); +} + +export function getFeatureFlagLabel(flagKey: string): FeatureFlagLabel { + const known = getKnownFlagLabels()[flagKey]; + if (known) { + return known; + } + const name = formatFlagKey(flagKey); + return { name, description: name }; +} diff --git a/application/account/WebApp/shared/translations/locale/da-DK.po b/application/account/WebApp/shared/translations/locale/da-DK.po index 61893a830..222f19180 100644 --- a/application/account/WebApp/shared/translations/locale/da-DK.po +++ b/application/account/WebApp/shared/translations/locale/da-DK.po @@ -423,6 +423,9 @@ msgstr "Basal support" msgid "Basis" msgstr "Basis" +msgid "Beta features" +msgstr "Betafunktioner" + msgid "Bill to" msgstr "Faktureres til" @@ -714,6 +717,9 @@ msgstr "Command" msgid "Command palette" msgstr "Kommandopalette" +msgid "Compact view" +msgstr "Kompakt visning" + msgid "Complete your payment to activate your subscription." msgstr "Gennemfør din betaling for at aktivere dit abonnement." @@ -810,6 +816,9 @@ msgstr "Nuværende side" msgid "Current plan" msgstr "Nuværende plan" +msgid "Custom branding" +msgstr "Brugerdefineret branding" + msgid "Customer deleted" msgstr "Kunde slettet" @@ -1075,6 +1084,9 @@ msgstr "Tom" msgid "Empty recycle bin" msgstr "Tøm papirkurv" +msgid "Enable branded login page" +msgstr "Aktivér branded loginside" + msgid "Enabled" msgstr "Aktiveret" @@ -1153,6 +1165,9 @@ msgstr "Mislykket" msgid "Failed to load payment processor." msgstr "Kunne ikke indlæse betalingsprocessor." +msgid "Feature updated" +msgstr "Funktion opdateret" + msgid "Features" msgstr "Funktioner" @@ -1783,6 +1798,9 @@ msgstr "Åbn sidepanel" msgid "Opened documentation" msgstr "Åbnet dokumentation" +msgid "Opt in to try new features before they are available to everyone." +msgstr "Tilmeld dig for at prøve nye funktioner, før de er tilgængelige for alle." + msgid "Option A" msgstr "Mulighed A" @@ -1955,6 +1973,9 @@ msgstr "Powered by" msgid "Pozole rojo" msgstr "Pozole rojo" +msgid "Preference updated" +msgstr "Præference opdateret" + msgid "Preferences" msgstr "Præferencer" @@ -2666,6 +2687,9 @@ msgstr "Skift fed" msgid "Toggle buttons" msgstr "Skifteknapper" +msgid "Toggle features available to your account." +msgstr "Slå funktioner til og fra for din konto." + msgid "Toggle group" msgstr "Skiftegruppe" @@ -2820,6 +2844,9 @@ msgstr "Upload profilbillede" msgid "Use a horizontal separator to group related sections in a column layout." msgstr "Brug en vandret separator til at gruppere relaterede sektioner i en kolonnelayout." +msgid "Use a more compact layout" +msgstr "Brug et mere kompakt layout" + msgid "Use Accordion to stack multiple related disclosable sections. Good for FAQs, settings groups, or any list where users scan items and expand a few." msgstr "Brug Accordion til at stable flere relaterede sektioner. God til FAQs, indstillingsgrupper eller lister hvor brugere scanner elementer og udvider et par stykker." diff --git a/application/account/WebApp/shared/translations/locale/en-US.po b/application/account/WebApp/shared/translations/locale/en-US.po index ec00a308b..52b11bd06 100644 --- a/application/account/WebApp/shared/translations/locale/en-US.po +++ b/application/account/WebApp/shared/translations/locale/en-US.po @@ -423,6 +423,9 @@ msgstr "Basic support" msgid "Basis" msgstr "Basis" +msgid "Beta features" +msgstr "Beta features" + msgid "Bill to" msgstr "Bill to" @@ -714,6 +717,9 @@ msgstr "Command" msgid "Command palette" msgstr "Command palette" +msgid "Compact view" +msgstr "Compact view" + msgid "Complete your payment to activate your subscription." msgstr "Complete your payment to activate your subscription." @@ -810,6 +816,9 @@ msgstr "Current page" msgid "Current plan" msgstr "Current plan" +msgid "Custom branding" +msgstr "Custom branding" + msgid "Customer deleted" msgstr "Customer deleted" @@ -1075,6 +1084,9 @@ msgstr "Empty" msgid "Empty recycle bin" msgstr "Empty recycle bin" +msgid "Enable branded login page" +msgstr "Enable branded login page" + msgid "Enabled" msgstr "Enabled" @@ -1153,6 +1165,9 @@ msgstr "Failed" msgid "Failed to load payment processor." msgstr "Failed to load payment processor." +msgid "Feature updated" +msgstr "Feature updated" + msgid "Features" msgstr "Features" @@ -1783,6 +1798,9 @@ msgstr "Open side pane" msgid "Opened documentation" msgstr "Opened documentation" +msgid "Opt in to try new features before they are available to everyone." +msgstr "Opt in to try new features before they are available to everyone." + msgid "Option A" msgstr "Option A" @@ -1955,6 +1973,9 @@ msgstr "Powered by" msgid "Pozole rojo" msgstr "Pozole rojo" +msgid "Preference updated" +msgstr "Preference updated" + msgid "Preferences" msgstr "Preferences" @@ -2666,6 +2687,9 @@ msgstr "Toggle bold" msgid "Toggle buttons" msgstr "Toggle buttons" +msgid "Toggle features available to your account." +msgstr "Toggle features available to your account." + msgid "Toggle group" msgstr "Toggle group" @@ -2820,6 +2844,9 @@ msgstr "Upload profile picture" msgid "Use a horizontal separator to group related sections in a column layout." msgstr "Use a horizontal separator to group related sections in a column layout." +msgid "Use a more compact layout" +msgstr "Use a more compact layout" + msgid "Use Accordion to stack multiple related disclosable sections. Good for FAQs, settings groups, or any list where users scan items and expand a few." msgstr "Use Accordion to stack multiple related disclosable sections. Good for FAQs, settings groups, or any list where users scan items and expand a few." From 716c40aa6cc37d34f1bd7781b5559036f4f25a14 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 16:05:17 +0200 Subject: [PATCH 008/155] Add back-office feature flag management UI with list and detail views --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 50 +++ .../routes/feature-flags/$flagKey.tsx | 178 ++++++++ .../-components/TenantOverridesSection.tsx | 110 +++++ .../routes/feature-flags/-components/types.ts | 32 ++ .../BackOffice/routes/feature-flags/index.tsx | 180 ++++++++ .../shared/components/BackOfficeSideMenu.tsx | 22 +- .../shared/translations/locale/da-DK.po | 385 ++++++++++++++---- 7 files changed, 883 insertions(+), 74 deletions(-) create mode 100644 application/account/Api/BackOffice/FeatureFlagEndpoints.cs create mode 100644 application/account/BackOffice/routes/feature-flags/$flagKey.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/types.ts create mode 100644 application/account/BackOffice/routes/feature-flags/index.tsx diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs new file mode 100644 index 000000000..77ea42578 --- /dev/null +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -0,0 +1,50 @@ +using Account.Features.FeatureFlags.Commands; +using Account.Features.FeatureFlags.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class FeatureFlagEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/feature-flags"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeFeatureFlags") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> (IMediator mediator) + => await mediator.Send(new GetFeatureFlagsQuery()) + ).Produces(); + + group.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) + => await mediator.Send(new GetFlagTenantsQuery { FlagKey = flagKey }) + ).Produces(); + + group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) + => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) + ); + + group.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) + => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) + ); + + group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ); + + group.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ); + } +} diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx new file mode 100644 index 000000000..317cfbee0 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -0,0 +1,178 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Switch } from "@repo/ui/components/Switch"; +import { TextField } from "@repo/ui/components/TextField"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowLeftIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import type { FeatureFlagInfo, GetFeatureFlagsResponse, GetFlagTenantsResponse } from "./-components/types"; + +import { TenantOverridesSection } from "./-components/TenantOverridesSection"; + +export const Route = createFileRoute("/feature-flags/$flagKey")({ + staticData: { trackingTitle: "Feature flag detail" }, + component: FlagDetailPage +}); + +export default function FlagDetailPage() { + const { flagKey } = Route.useParams(); + + const { data: flagsData, isLoading: isLoadingFlags } = api.useQuery("get", "/api/back-office/feature-flags") as { + data: GetFeatureFlagsResponse | undefined; + isLoading: boolean; + }; + + const { data: tenantsData, isLoading: isLoadingTenants } = api.useQuery( + "get", + "/api/back-office/feature-flags/{flagKey}/tenants", + { params: { path: { flagKey } } } + ) as { + data: GetFlagTenantsResponse | undefined; + isLoading: boolean; + }; + + const flag = flagsData?.flags?.find((f) => f.key === flagKey); + const isLoading = isLoadingFlags || isLoadingTenants; + + return ( + + + + + + + + {flag?.description ?? flagKey} + + } + > + {isLoading ? ( + + ) : flag ? ( +
+ + {flag.scope === "Tenant" && ( + + )} +
+ ) : null} +
+
+
+ ); +} + +function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { + const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); + const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); + const isPending = activateMutation.isPending || deactivateMutation.isPending; + + const handleToggle = (checked: boolean) => { + const mutation = checked ? activateMutation : deactivateMutation; + mutation.mutate( + { params: { path: { flagKey: flag.key } } }, + { + onSuccess: () => { + toast.success(checked ? t`Feature flag activated` : t`Feature flag deactivated`); + } + } + ); + }; + + return ( +
+
+
+ + Type + + {flag.scope} +
+
+ {flag.isActive ? t`Active` : t`Inactive`} + +
+
+ {flag.isAbTestEligible && ( + + )} +
+ ); +} + +function RolloutPercentageInput({ + flagKey, + currentPercentage +}: Readonly<{ + flagKey: string; + currentPercentage: number | null; +}>) { + const [percentage, setPercentage] = useState(String(currentPercentage ?? 0)); + + const rolloutMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/rollout-percentage"); + + const handleSave = () => { + const value = Number.parseInt(percentage, 10); + if (Number.isNaN(value) || value < 0 || value > 100) return; + + rolloutMutation.mutate( + { + params: { path: { flagKey } }, + body: { rolloutPercentage: value } + }, + { + onSuccess: () => { + toast.success(t`Rollout percentage updated`); + } + } + ); + }; + + return ( +
+ setPercentage(value)} + className="w-32" + /> + +
+ ); +} + +function FlagDetailSkeleton() { + return ( +
+ +
+ + + + +
+
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx new file mode 100644 index 000000000..14d829231 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -0,0 +1,110 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Switch } from "@repo/ui/components/Switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { toast } from "sonner"; + +import { api } from "@/shared/lib/api/client"; + +import type { FlagTenantInfo } from "./types"; + +export function TenantOverridesSection({ + flagKey, + tenants +}: Readonly<{ + flagKey: string; + tenants: FlagTenantInfo[]; +}>) { + return ( +
+

+ Tenant overrides +

+
+ + + + + Tenant + + + Status + + + Source + + + Override + + + + + {tenants.map((tenant) => ( + + ))} + +
+
+
+ ); +} + +function TenantOverrideRow({ + flagKey, + tenant +}: Readonly<{ + flagKey: string; + tenant: FlagTenantInfo; +}>) { + const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + + const handleToggle = (checked: boolean) => { + overrideMutation.mutate( + { + params: { path: { flagKey } }, + body: { tenantId: tenant.tenantId, enabled: checked } + }, + { + onSuccess: () => { + toast.success(t`Tenant override updated`); + } + } + ); + }; + + const sourceLabel = getSourceLabel(tenant.source); + + return ( + + {tenant.tenantName} + + {tenant.isEnabled ? t`Enabled` : t`Disabled`} + + + {sourceLabel} + + + + + + ); +} + +function getSourceLabel(source: string): string { + switch (source) { + case "manual_override": + return t`Manual override`; + case "ab_rollout": + return t`A/B rollout`; + case "default": + return t`Default`; + default: + return source; + } +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts new file mode 100644 index 000000000..2a4c2dae2 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -0,0 +1,32 @@ +export type FeatureFlagScope = "System" | "Tenant" | "User"; + +export interface FeatureFlagInfo { + key: string; + scope: FeatureFlagScope; + adminLevel: string; + description: string; + isAbTestEligible: boolean; + configurableByTenant: boolean; + configurableByUser: boolean; + enabledAt: string | null; + disabledAt: string | null; + bucketStart: number | null; + bucketEnd: number | null; + rolloutPercentage: number | null; + isActive: boolean; +} + +export interface GetFeatureFlagsResponse { + flags: FeatureFlagInfo[]; +} + +export interface FlagTenantInfo { + tenantId: number; + tenantName: string; + isEnabled: boolean; + source: "manual_override" | "ab_rollout" | "default"; +} + +export interface GetFlagTenantsResponse { + tenants: FlagTenantInfo[]; +} diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx new file mode 100644 index 000000000..ebd29aec4 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -0,0 +1,180 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Badge } from "@repo/ui/components/Badge"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Switch } from "@repo/ui/components/Switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import type { FeatureFlagInfo, GetFeatureFlagsResponse } from "./-components/types"; + +type TabFilter = "all" | "Tenant" | "User" | "System"; + +export const Route = createFileRoute("/feature-flags/")({ + staticData: { trackingTitle: "Feature flags" }, + component: FeatureFlagsPage +}); + +export default function FeatureFlagsPage() { + const [activeTab, setActiveTab] = useState("all"); + const { data, isLoading } = api.useQuery("get", "/api/back-office/feature-flags") as { + data: GetFeatureFlagsResponse | undefined; + isLoading: boolean; + }; + + const filteredFlags = useMemo(() => { + if (!data?.flags) return []; + if (activeTab === "all") return data.flags; + return data.flags.filter((flag) => flag.scope === activeTab); + }, [data?.flags, activeTab]); + + return ( + + + + + setActiveTab(value as TabFilter)} + className="relative z-10 mb-4" + > + + + All + + + Tenant + + + User + + + System + + + + + {isLoading ? : } + + + + + + ); +} + +function FeatureFlagTable({ flags }: Readonly<{ flags: FeatureFlagInfo[] }>) { + const navigate = useNavigate(); + + return ( +
+ + + + + Flag name + + + Type + + + Rollout + + + Status + + + + + {flags.map((flag) => ( + { + if (flag.scope === "Tenant") { + navigate({ to: "/feature-flags/$flagKey", params: { flagKey: flag.key } }); + } + }} + /> + ))} + +
+
+ ); +} + +function FeatureFlagRow({ + flag, + onClick +}: Readonly<{ + flag: FeatureFlagInfo; + onClick: () => void; +}>) { + const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); + const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); + const isPending = activateMutation.isPending || deactivateMutation.isPending; + + const handleToggle = (checked: boolean) => { + if (flag.scope === "System") return; + + const mutation = checked ? activateMutation : deactivateMutation; + mutation.mutate( + { params: { path: { flagKey: flag.key } } }, + { + onSuccess: () => { + toast.success(checked ? t`Feature flag activated` : t`Feature flag deactivated`); + } + } + ); + }; + + const isTenantScoped = flag.scope === "Tenant"; + + return ( + + {flag.description} + + {flag.scope} + + + {flag.rolloutPercentage !== null ? ( + `${flag.rolloutPercentage}%` + ) : ( + -- + )} + + + {flag.scope === "System" ? ( + {flag.isActive ? t`Active` : t`Inactive`} + ) : ( + event.stopPropagation()} + /> + )} + + + ); +} + +function FeatureFlagsSkeleton() { + return ( +
+ + + + +
+ ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx index 4c7ceddbf..f88d4cace 100644 --- a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx @@ -13,7 +13,7 @@ import { SidebarRail } from "@repo/ui/components/Sidebar"; import { Link as RouterLink, useRouter } from "@tanstack/react-router"; -import { Building2Icon, HomeIcon, ReceiptIcon, UsersIcon, ZapIcon } from "lucide-react"; +import { Building2Icon, FlagIcon, HomeIcon, ReceiptIcon, UsersIcon, ZapIcon } from "lucide-react"; import { BackOfficeAvatarMenu } from "./BackOfficeAvatarMenu"; @@ -28,6 +28,7 @@ export function BackOfficeSideMenu() { const isUsersActive = currentPath === "/users" || currentPath.startsWith("/users/"); const isBillingEventsActive = currentPath === "/billing-events" || currentPath.startsWith("/billing-events/"); const isInvoicesActive = currentPath === "/invoices" || currentPath.startsWith("/invoices/"); + const isFeatureFlagsActive = currentPath === "/feature-flags" || currentPath.startsWith("/feature-flags/"); return ( @@ -106,6 +107,25 @@ export function BackOfficeSideMenu() { )} + + + Platform + + + + + + + + + Feature flags + + + + + + + diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index dbccce70e..c4ba5ac95 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -21,6 +21,11 @@ msgstr "{0, plural, one {# time siden} other {# timer siden}}" msgid "{0, plural, one {# minute ago} other {# minutes ago}}" msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" +#. placeholder {0}: group.plan +#. placeholder {1}: group.tenants.length +msgid "{0} ({1})" +msgstr "" + #. placeholder {0}: formatCurrency(blended, currency) msgid "{0} blended" msgstr "{0} samlet" @@ -41,6 +46,16 @@ msgstr "{count, plural, one {# konto er endnu ikke synkroniseret — MRR-tendens msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" +#. placeholder {0}: tenant.tenantName +#. placeholder {0}: user.email +msgid "{featureFlagDescription} disabled for {0}" +msgstr "" + +#. placeholder {0}: tenant.tenantName +#. placeholder {0}: user.email +msgid "{featureFlagDescription} enabled for {0}" +msgstr "" + msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inaktive" @@ -87,11 +102,17 @@ msgstr "7d" msgid "90d" msgstr "90d" +msgid "A/B rollout" +msgstr "" + msgid "Account" msgstr "Konto" msgid "Account actions" -msgstr "Kontohandlinger" +msgstr "" + +msgid "Account flags" +msgstr "" msgid "Account growth" msgstr "Kontovækst" @@ -101,9 +122,15 @@ msgstr "Kontovækst" msgid "Account has {0} drift discrepancies. Last reconciled at {1}. If standard reconcile cannot clear the drift, disaster recovery from archived Stripe events is available as a last resort." msgstr "Kontoen har {0} afvigelser. Sidst afstemt {1}. Hvis standard-afstemning ikke kan rydde afvigelserne, er katastrofegendannelse fra arkiverede Stripe-events tilgængelig som sidste udvej." +msgid "Account ID" +msgstr "Konto-ID" + msgid "Account preview" msgstr "Kontoforhåndsvisning" +msgid "Account status" +msgstr "" + msgid "Account users" msgstr "Kontobrugere" @@ -113,6 +140,12 @@ msgstr "konti" msgid "Accounts" msgstr "Konti" +msgid "Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are available for plan-managed flags." +msgstr "" + +msgid "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." +msgstr "" + msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." @@ -135,10 +168,16 @@ msgid "All" msgstr "Alle" msgid "All accounts this user is a member of, with their plan and role." -msgstr "Alle konti, brugeren er medlem af, med deres plan og rolle." +msgstr "" + +msgid "All event types" +msgstr "Alle hændelsestyper" + +msgid "All statuses" +msgstr "Alle statusser" msgid "All users across every account, most recently seen first. Search and filter to narrow down." -msgstr "Alle brugere på tværs af konti, senest sete først. Søg og filtrér for at indsnævre." +msgstr "" msgid "All-time" msgstr "Samlet" @@ -146,6 +185,9 @@ msgstr "Samlet" msgid "All-time, excluding VAT" msgstr "Hele perioden, eksklusiv moms" +msgid "Allow users to authenticate using enterprise identity providers" +msgstr "Tillad brugere at logge ind via virksomhedsidentitetsudbydere" + msgid "Amount" msgstr "Beløb" @@ -155,14 +197,17 @@ msgstr "Der opstod en uventet fejl ved behandlingen." #. placeholder {0}: result.billingEventsAppended #. placeholder {1}: formatDate(result.reconciledAt) msgid "Appended {0} new billing events. Last reconciled at {1}." -msgstr "Tilføjede {0} nye faktureringshændelser. Sidst afstemt {1}." +msgstr "" msgid "Authoritative log of subscription, payment, and billing transitions across all accounts." -msgstr "Autoritativ log over abonnements-, betalings- og faktureringsændringer på tværs af alle konti." +msgstr "" msgid "Back Office" msgstr "Back Office" +msgid "Back to feature flags" +msgstr "" + msgid "Back Office - Localhost" msgstr "Back Office - Localhost" @@ -172,20 +217,23 @@ msgstr "Back Office oversigt · {today}" msgid "Basis" msgstr "Basis" +msgid "Beta features" +msgstr "" + msgid "Billing" -msgstr "Fakturering" +msgstr "" msgid "Billing address" msgstr "Faktureringsadresse" msgid "Billing events" -msgstr "Faktureringshændelser" +msgstr "" msgid "Billing info added" -msgstr "Faktureringsinfo tilføjet" +msgstr "" msgid "Billing info updated" -msgstr "Faktureringsinfo opdateret" +msgstr "" msgid "blended" msgstr "blandet" @@ -196,8 +244,11 @@ msgstr "Samlet MRR" msgid "Browser" msgstr "Browser" +msgid "Bucket" +msgstr "" + msgid "Cancel" -msgstr "Annuller" +msgstr "" msgid "Canceled" msgstr "Opsagt" @@ -209,10 +260,10 @@ msgid "Cancellation" msgstr "Opsigelse" msgid "Cancelled" -msgstr "Annulleret" +msgstr "" msgid "Cancelled immediately" -msgstr "Annulleret med det samme" +msgstr "" msgid "Change language" msgstr "Skift sprog" @@ -233,11 +284,14 @@ msgid "Clear search" msgstr "Ryd søgning" msgid "Close" -msgstr "Luk" +msgstr "" msgid "Close account preview" msgstr "Luk kontoforhåndsvisning" +msgid "Compact view" +msgstr "" + msgid "Contact your administrator." msgstr "Kontakt din administrator." @@ -270,7 +324,7 @@ msgstr "Dashboard" #. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, currency) #. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, currency) msgid "Dashboard MRR mismatch: KPI shows {0}, trend latest shows {1}." -msgstr "MRR-uoverensstemmelse på dashboard: KPI viser {0}, seneste tendens viser {1}." +msgstr "" msgid "Date" msgstr "Dato" @@ -284,6 +338,14 @@ msgstr "Computer" msgid "Device" msgstr "Enhed" +msgid "Disabled" +msgstr "Deaktiveret" + +#. placeholder {0}: disabledTenants.length +#. placeholder {0}: disabledUsers.length +msgid "Disabled ({0})" +msgstr "Deaktiveret ({0})" + msgid "Disaster recovery complete" msgstr "Katastrofegendannelse fuldført" @@ -291,10 +353,10 @@ msgid "Disaster recovery from archived Stripe events?" msgstr "Katastrofegendannelse fra arkiverede Stripe-events?" msgid "Downgrade cancelled" -msgstr "Nedgradering annulleret" +msgstr "" msgid "Downgrade scheduled" -msgstr "Nedgradering planlagt" +msgstr "" msgid "Downgraded" msgstr "Nedgraderet" @@ -305,6 +367,12 @@ msgstr "Nedgraderer" msgid "Drift detected" msgstr "Afvigelser fundet" +msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." +msgstr "" + +msgid "Early access to experimental features before general availability" +msgstr "" + msgid "Email" msgstr "E-mail" @@ -314,36 +382,65 @@ msgstr "E-mail bekræftet" msgid "Email pending" msgstr "E-mail afventer" +msgid "Enabled" +msgstr "" + +#. placeholder {0}: enabledTenants.length +#. placeholder {0}: enabledUsers.length +msgid "Enabled ({0})" +msgstr "" + +#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) +#. placeholder {1}: formatTimestamp(featureFlag.disabledAt) +msgid "Enabled period: {0} - {1}" +msgstr "" + +#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) +msgid "Enabled: {0}" +msgstr "" + msgid "Enter kiosk mode" -msgstr "Aktivér kiosktilstand" +msgstr "" msgid "Event" -msgstr "Hændelse" +msgstr "" msgid "Event view" msgstr "Hændelsesvisning" msgid "Every invoice, refund, and credit note — the money in and out for this subscription." -msgstr "Alle fakturaer, refusioner og kreditnotaer — pengene ind og ud for dette abonnement." +msgstr "" msgid "Every invoice, refund, and credit note across all accounts." -msgstr "Alle fakturaer, refusioner og kreditnotaer på tværs af alle konti." +msgstr "" msgid "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." -msgstr "Alle log-ind-forsøg de seneste 30 dage, lykkedes eller mislykkedes, på tværs af e-mail og eksterne udbydere." +msgstr "" msgid "Exit kiosk mode" -msgstr "Afslut kiosktilstand" +msgstr "" + +msgid "Experimental UI" +msgstr "" msgid "Expired" -msgstr "Udløbet" +msgstr "" msgid "Expires" -msgstr "Udløber" +msgstr "" msgid "Failed" msgstr "Mislykket" +msgid "Feature flag activated" +msgstr "" + +msgid "Feature flag deactivated" +msgstr "" + +msgid "Feature flags" +msgstr "Feature flags" + msgid "Free" msgstr "Gratis" @@ -353,6 +450,9 @@ msgstr "Gå til forsiden" msgid "Google" msgstr "Google" +msgid "Google OAuth" +msgstr "" + msgid "Hide details" msgstr "Skjul detaljer" @@ -366,10 +466,10 @@ msgid "Invoice view" msgstr "Fakturavisning" msgid "Invoices" -msgstr "Fakturaer" +msgstr "" msgid "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." -msgstr "Fakturaer vises her, når konti tilmelder sig, og Stripe-webhooks behandles." +msgstr "" msgid "IP address" msgstr "IP-adresse" @@ -429,7 +529,7 @@ msgid "Login history" msgstr "Login-historik" msgid "Logins" -msgstr "Logins" +msgstr "" msgid "Logo" msgstr "Logo" @@ -437,6 +537,12 @@ msgstr "Logo" msgid "Main navigation" msgstr "Hovednavigation" +msgid "Manage feature flags across the platform." +msgstr "" + +msgid "Manual override" +msgstr "" + msgid "Member" msgstr "Medlem" @@ -453,10 +559,10 @@ msgid "MRR" msgstr "MRR" msgid "MRR after" -msgstr "MRR efter" +msgstr "" msgid "MRR impact" -msgstr "MRR-effekt" +msgstr "" msgid "MRR trend" msgstr "MRR-tendens" @@ -464,6 +570,9 @@ msgstr "MRR-tendens" msgid "Name" msgstr "Navn" +msgid "Name:" +msgstr "" + msgid "Navigation" msgstr "Navigation" @@ -479,6 +588,9 @@ msgstr "Næste" msgid "No account memberships" msgstr "Ingen kontomedlemskaber" +msgid "No accounts in this group." +msgstr "" + msgid "No accounts match your filters" msgstr "Ingen konti matcher dine filtre" @@ -492,22 +604,25 @@ msgid "No billing address on file." msgstr "Ingen faktureringsadresse registreret." msgid "No billing events" -msgstr "Ingen faktureringshændelser" +msgstr "" msgid "No billing events match your filters" -msgstr "Ingen faktureringshændelser matcher dine filtre" +msgstr "" msgid "No billing events yet" -msgstr "Ingen faktureringshændelser endnu" +msgstr "" msgid "No change" msgstr "Ingen ændring" +msgid "No invoices match your filters" +msgstr "Ingen fakturaer matcher dine filtre" + msgid "No invoices yet" -msgstr "Ingen fakturaer endnu" +msgstr "" msgid "No invoices, refunds, or credit notes yet." -msgstr "Ingen fakturaer, refusioner eller kreditnotaer endnu." +msgstr "" msgid "No login history" msgstr "Ingen login-historik" @@ -516,7 +631,7 @@ msgid "No matching users" msgstr "Ingen matchende brugere" msgid "No new billing events were appended. Account state matches Stripe." -msgstr "Ingen nye faktureringshændelser blev tilføjet. Kontotilstand matcher Stripe." +msgstr "" msgid "No owners" msgstr "Ingen ejere" @@ -531,13 +646,13 @@ msgid "No plan" msgstr "Intet abonnement" msgid "No recent billing events" -msgstr "Ingen nylige faktureringshændelser" +msgstr "" msgid "No recent logins" -msgstr "Ingen nylige logins" +msgstr "" msgid "No recent payments" -msgstr "Ingen nylige betalinger" +msgstr "" msgid "No recent signups" msgstr "Ingen nylige tilmeldinger" @@ -549,7 +664,7 @@ msgid "No refunds or credit notes yet" msgstr "Ingen refunderinger eller kreditnotaer endnu" msgid "No result available." -msgstr "Intet resultat tilgængeligt." +msgstr "" msgid "No results match your search" msgstr "Ingen resultater matcher din søgning" @@ -561,11 +676,14 @@ msgid "No sign-in attempts in the last 30 days." msgstr "Ingen login-forsøg de seneste 30 dage." msgid "No transactions" -msgstr "Ingen transaktioner" +msgstr "" msgid "No users" msgstr "Ingen brugere" +msgid "No users found" +msgstr "" + msgid "No users match your filters." msgstr "Ingen brugere matcher dine filtre." @@ -573,16 +691,16 @@ msgid "No users match your search" msgstr "Ingen brugere matcher din søgning" msgid "No users yet" -msgstr "Ingen brugere endnu" +msgstr "" msgid "Not synced yet" msgstr "Ikke synkroniseret endnu" msgid "Occurred" -msgstr "Tidspunkt" +msgstr "" msgid "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." -msgstr "En række pr. enhed eller browser, brugeren er logget ind fra. Tilbagekaldte sessioner kan ikke logge ind igen." +msgstr "" msgid "One-time password" msgstr "Engangskode" @@ -594,7 +712,7 @@ msgid "Open credit note" msgstr "Åbn kreditnota" msgid "Open in Stripe" -msgstr "Åbn i Stripe" +msgstr "" msgid "Open invoice" msgstr "Åbn faktura" @@ -608,6 +726,19 @@ msgstr "Resultat" msgid "over period" msgstr "over perioden" +msgid "Override" +msgstr "Tilsidesættelse" + +#. placeholder {0}: tenant.tenantName +#. placeholder {0}: user.email +msgid "Override for {0}" +msgstr "Tilsidesættelse for {0}" + +#. placeholder {0}: tenant.tenantName +#. placeholder {0}: user.email +msgid "Override removed for {0}" +msgstr "Tilsidesættelse fjernet for {0}" + msgid "Overview" msgstr "Overblik" @@ -621,7 +752,7 @@ msgid "Page not found" msgstr "Siden blev ikke fundet" msgid "Paid" -msgstr "Betalt" +msgstr "" msgid "Past due" msgstr "Forfalden" @@ -630,16 +761,16 @@ msgid "Payment failed" msgstr "Betaling mislykkedes" msgid "Payment method" -msgstr "Betalingsmetode" +msgstr "" msgid "Payment method updated" -msgstr "Betalingsmetode opdateret" +msgstr "" msgid "Payment recovered" -msgstr "Betaling gendannet" +msgstr "" msgid "Payment refunded" -msgstr "Betaling refunderet" +msgstr "" msgid "Pending" msgstr "Afventer" @@ -657,13 +788,19 @@ msgid "Plan & revenue" msgstr "Abonnement og omsætning" msgid "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." -msgstr "Planændringer, fornyelser, opsigelser og betalingsresultater — abonnementets livscyklus og dets MRR-effekt over tid." +msgstr "" msgid "Plan distribution" msgstr "Plan-fordeling" +msgid "Plan flags" +msgstr "" + msgid "Plan transition" -msgstr "Abonnementsændring" +msgstr "" + +msgid "Platform" +msgstr "" msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -684,28 +821,28 @@ msgid "Prior period" msgstr "Forrige periode" msgid "Reactivated" -msgstr "Genaktiveret" +msgstr "" msgid "Recent billing events" -msgstr "Nylige faktureringshændelser" +msgstr "" msgid "Recent logins" -msgstr "Nylige logins" +msgstr "" msgid "Recent payments" -msgstr "Nylige betalinger" +msgstr "" msgid "Recent signups" msgstr "Nylige tilmeldinger" msgid "Reconcile" -msgstr "Afstem" +msgstr "" msgid "Reconcile complete" -msgstr "Afstemning fuldført" +msgstr "" msgid "Reconcile complete with drift detected" -msgstr "Afstemning fuldført med afvigelser fundet" +msgstr "" #. placeholder {0}: archivedAwaiting.count #. placeholder {1}: formatDate(archivedAwaiting.oldestOccurredAt) @@ -717,13 +854,16 @@ msgid "Reconcile rebuilds this tenant's subscription and billing events from Str msgstr "Afstemning genopbygger denne kontos abonnement og faktureringshændelser direkte fra Stripe via events.list-API'et (de seneste 30 dage). Hvis afvigelserne ikke er ryddet bagefter, er katastrofegendannelse fra de lokalt arkiverede Stripe-payloads tilgængelig som sidste udvej." msgid "Reconcile with Stripe" -msgstr "Afstem med Stripe" +msgstr "" msgid "Reconcile with Stripe?" -msgstr "Afstem med Stripe?" +msgstr "" msgid "Reconciling..." -msgstr "Afstemmer..." +msgstr "" + +msgid "Reduce spacing between UI elements for a denser layout" +msgstr "" msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Posteringer vises her, når konti abonnerer, og Stripe-webhooks behandles." @@ -737,6 +877,14 @@ msgstr "Refunderinger og kreditnotaer" msgid "Refunds and credit notes across all accounts." msgstr "Refunderinger og kreditnotaer på tværs af alle konti." +msgid "Remove override" +msgstr "Fjern tilsidesættelse" + +#. placeholder {0}: tenant.tenantName +#. placeholder {0}: user.email +msgid "Remove override for {0}" +msgstr "Fjern tilsidesættelse for {0}" + msgid "Renewal" msgstr "Fornyelse" @@ -744,7 +892,7 @@ msgid "Renewal date" msgstr "Fornyelsesdato" msgid "Renewed" -msgstr "Fornyet" +msgstr "" #. placeholder {0}: formatDate(membership.renewalDate) #. placeholder {0}: formatDate(tenant.renewalDate) @@ -756,6 +904,12 @@ msgstr "Fornyes {0}" msgid "Replayed {0} archived events into the billing event ledger at {1}." msgstr "Afspillede {0} arkiverede hændelser til faktureringshændelseslogen {1}." +msgid "Required plan" +msgstr "Krævet abonnement" + +msgid "Required plan:" +msgstr "Krævet abonnement:" + msgid "Revenue" msgstr "Omsætning" @@ -765,6 +919,24 @@ msgstr "Tilbagekaldt" msgid "Role" msgstr "Rolle" +msgid "Rollout" +msgstr "Udrulning" + +msgid "Rollout %" +msgstr "Udrulning %" + +msgid "Rollout bucket information" +msgstr "Information om udrulningsspand" + +msgid "Rollout buckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsspande: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" + +msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsspande: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({rolloutPercentage}%)" + +msgid "Rollout percentage updated" +msgstr "Udrulningsprocent opdateret" + msgid "Run disaster recovery" msgstr "Kør katastrofegendannelse" @@ -778,7 +950,13 @@ msgid "Search" msgstr "Søg" msgid "Search by account name" -msgstr "Søg efter kontonavn" +msgstr "" + +msgid "Search by account name or ID" +msgstr "" + +msgid "Search by email" +msgstr "" msgid "Search by email, name, or account" msgstr "Søg på e-mail, navn eller konto" @@ -789,6 +967,15 @@ msgstr "Søg efter navn" msgid "Search by name or email" msgstr "Søg efter navn eller e-mail" +msgid "Search for users" +msgstr "" + +msgid "Search for users by email and toggle the override switch to enable this feature." +msgstr "" + +msgid "Search results" +msgstr "" + msgid "Search users" msgstr "Søg brugere" @@ -801,6 +988,9 @@ msgstr "Sessioner" msgid "Show details" msgstr "Vis detaljer" +msgid "Sign in with Google using OpenID Connect" +msgstr "" + msgid "Signed up" msgstr "Tilmeldt" @@ -813,18 +1003,30 @@ msgstr "Tilmeldt <0>{0}<1>{1}" msgid "Since {0}" msgstr "Siden {0}" +msgid "Single sign-on" +msgstr "Enkeltlogon" + +msgid "Skip replay" +msgstr "Spring afspilning over" + msgid "Small" msgstr "Lille" msgid "Something went wrong" msgstr "Noget gik galt" +msgid "Source" +msgstr "" + msgid "Standard" msgstr "Standard" msgid "Status" msgstr "Status" +msgid "Stripe-powered subscription billing and plan management" +msgstr "" + msgid "Subscribed" msgstr "Tilmeldt" @@ -835,10 +1037,13 @@ msgid "Subscription state" msgstr "Abonnementstilstand" msgid "Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed." -msgstr "Abonnements-, betalings- og faktureringsændringer vises her, når Stripe-webhooks behandles." +msgstr "" msgid "Subscription, payment, and billing transitions will appear here." -msgstr "Abonnements-, betalings- og faktureringsændringer vises her." +msgstr "" + +msgid "Subscriptions" +msgstr "" msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." @@ -850,7 +1055,7 @@ msgid "Succeeded" msgstr "Gennemført" msgid "Successful logins will appear here as users sign in." -msgstr "Vellykkede logins vises her, når brugere logger ind." +msgstr "" msgid "Successful, pending, and failed invoices across all accounts." msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle konti." @@ -861,6 +1066,9 @@ msgstr "Suspenderet" msgid "System" msgstr "System" +msgid "System flags" +msgstr "" + msgid "Tablet" msgstr "Tablet" @@ -876,6 +1084,12 @@ msgstr "Denne konto har ingen brugere." msgid "this period" msgstr "denne periode" +msgid "This account previously had a paid subscription that ended." +msgstr "Denne konto havde tidligere et betalt abonnement, der sluttede." + +msgid "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." +msgstr "Dette flag administreres af abonnementet. Det aktiveres automatisk for konti på det krævede abonnement eller højere." + msgid "This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort recovery that may produce incorrect subscription state or billing event rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." msgstr "Dette genopbygger faktureringshændelsesloggen fra denne kontos arkiverede Stripe-payloads. Det er en bedst muligt-gendannelse, der kan producere forkerte abonnementstilstande eller faktureringshændelser. Kør kun denne handling, når standard Afstem med Stripe er forsøgt og ikke ryddede afvigelserne." @@ -885,8 +1099,15 @@ msgstr "Denne bruger har ingen registrerede sessioner." msgid "This user is not a member of any account." msgstr "Denne bruger er ikke medlem af nogen konto." +#. placeholder {0}: getFeatureFlagName(featureFlag.key) +msgid "Toggle {0}" +msgstr "" + +msgid "Toggle the override switch to enable this feature for specific accounts." +msgstr "" + msgid "Total" -msgstr "I alt" +msgstr "" msgid "Total accounts" msgstr "Konti i alt" @@ -897,6 +1118,9 @@ msgstr "Omsætning i alt" msgid "Try a different search term or clear the role and activity filters." msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." +msgid "Try adjusting your search" +msgstr "" + msgid "Try again" msgstr "Prøv igen" @@ -906,6 +1130,12 @@ msgstr "Prøv at rydde søgningen eller filtrene for at se flere resultater." msgid "Try clearing the search to see more results." msgstr "Prøv at rydde søgningen for at se flere resultater." +msgid "Try out experimental user interface components" +msgstr "Afprøv eksperimentelle brugergrænsefladekomponenter" + +msgid "Type an email address above to find users and manage their overrides" +msgstr "Indtast en e-mailadresse ovenfor for at finde brugere og administrere deres tilsidesættelser" + msgid "Unclassified" msgstr "Uklassificeret" @@ -921,41 +1151,50 @@ msgstr "Bruger" msgid "User detail" msgstr "Brugerdetalje" +msgid "User flags" +msgstr "" + msgid "User logins / day" msgstr "Brugerlogins / dag" msgid "User menu" msgstr "Brugermenu" +msgid "User status" +msgstr "" + msgid "Users" msgstr "Brugere" msgid "Users active" msgstr "Aktive brugere" +msgid "Users are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific users." +msgstr "" + msgid "Users will appear here as accounts are created." -msgstr "Brugere vises her, efterhånden som konti oprettes." +msgstr "" msgid "VAT" -msgstr "Moms" +msgstr "" msgid "VAT number" msgstr "Momsnummer" msgid "View accounts" -msgstr "Vis konti" +msgstr "" msgid "View all" msgstr "Vis alle" msgid "View all {totalEvents} events" -msgstr "Vis alle {totalEvents} hændelser" +msgstr "" msgid "View all {totalTransactions} invoices" -msgstr "Vis alle {totalTransactions} fakturaer" +msgstr "" msgid "View billing events" -msgstr "Vis faktureringshændelser" +msgstr "" msgid "vs prior period" msgstr "mod forrige periode" From 030f086df393806804ff97278a97b74477c56d15 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 19:04:53 +0200 Subject: [PATCH 009/155] Fix back-office cross-SCS proxy service discovery and antiforgery --- .../account/Api/BackOffice/FeatureFlagEndpoints.cs | 8 ++++---- application/account/Api/Endpoints/FeatureFlagEndpoints.cs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 77ea42578..dd9bb792b 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -33,18 +33,18 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) - ); + ).DisableAntiforgery(); group.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) - ); + ).DisableAntiforgery(); group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ); + ).DisableAntiforgery(); group.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ); + ).DisableAntiforgery(); } } diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 86bd9e010..95350a24c 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -23,19 +23,19 @@ public void MapEndpoints(IEndpointRouteBuilder routes) internalGroup.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) - ); + ).DisableAntiforgery(); internalGroup.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) - ); + ).DisableAntiforgery(); internalGroup.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ); + ).DisableAntiforgery(); internalGroup.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ); + ).DisableAntiforgery(); // Authenticated API endpoints (tenant owner and user operations) var group = routes.MapGroup("/api/account/feature-flags").WithTags("FeatureFlags").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem(); From 8fa48ea600248a6fe70ea92a80fb801b0c791b28 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 19:05:09 +0200 Subject: [PATCH 010/155] Add back-office feature flag management E2E smoke test --- .../tests/e2e/feature-flag-flows.spec.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts new file mode 100644 index 000000000..55562dc91 --- /dev/null +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -0,0 +1,112 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { getBackOfficeBaseUrl } from "@shared/e2e/utils/constants"; +import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +const BACK_OFFICE_BASE_URL = getBackOfficeBaseUrl(); + +test.describe("@smoke", () => { + /** + * FEATURE FLAG SYSTEM E2E TEST + * + * Tests the back-office feature flag management flow: + * - Flag list: view flags, filter by scope tabs, toggle a flag via switch + * - Flag detail: navigate into tenant-scoped flag, toggle tenant override, set A/B rollout percentage + */ + test("should manage feature flags across back-office flag list & detail views", async ({ browser }) => { + const backOfficeContext = await browser.newContext({ baseURL: BACK_OFFICE_BASE_URL, ignoreHTTPSErrors: true }); + const page = await backOfficeContext.newPage(); + const context = createTestContext(page); + + await step("Log in as Admin via MockEasyAuth & verify redirect to feature flags page")(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags`); + + await expect(page.getByRole("radio", { name: "Admin Log in with admin rights" })).toBeVisible(); + await page.getByRole("radio", { name: "Admin Log in with admin rights" }).click(); + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags`); + })(); + + // === BACK-OFFICE FLAG LIST === + + await step("Load feature flags page & verify flag list renders with expected flags")(async () => { + await expect(page.getByRole("heading", { name: "Feature flags" })).toBeVisible(); + + const table = page.getByRole("table", { name: "Feature flags" }); + await expect(table.getByText("Google OAuth authentication")).toBeVisible(); + await expect(table.getByText("Subscription billing via Stripe")).toBeVisible(); + await expect(table.getByText("Enables beta features for tenants")).toBeVisible(); + await expect(table.getByText("Enables single sign-on for tenants")).toBeVisible(); + await expect(table.getByText("Enables custom branding options for tenants")).toBeVisible(); + await expect(table.getByText("Enables compact view in the user interface")).toBeVisible(); + })(); + + await step("Filter by Tenant tab & verify only tenant-scoped flags appear")(async () => { + await page.getByRole("tab", { name: "Tenant" }).click(); + + const table = page.getByRole("table", { name: "Feature flags" }); + await expect(table.getByText("Enables beta features for tenants")).toBeVisible(); + await expect(table.getByText("Enables single sign-on for tenants")).toBeVisible(); + await expect(table.getByText("Enables custom branding options for tenants")).toBeVisible(); + await expect(table.getByText("Google OAuth authentication")).not.toBeVisible(); + await expect(table.getByText("Enables compact view in the user interface")).not.toBeVisible(); + })(); + + await step("Switch back to All tab & verify all flags visible again")(async () => { + await page.getByRole("tab", { name: "All" }).click(); + + const table = page.getByRole("table", { name: "Feature flags" }); + await expect(table.getByText("Google OAuth authentication")).toBeVisible(); + await expect(table.getByText("Enables compact view in the user interface")).toBeVisible(); + })(); + + await step("Toggle compact view flag & verify success toast")(async () => { + const toggle = page.getByRole("switch", { + name: "Toggle Enables compact view in the user interface" + }); + await toggle.click(); + + await expectToastMessage(context, "Feature flag"); + })(); + + // === BACK-OFFICE FLAG DETAIL === + + await step("Click into beta-features flag detail & verify detail page loads")(async () => { + const table = page.getByRole("table", { name: "Feature flags" }); + const betaRow = table.locator("tr").filter({ hasText: "Enables beta features for tenants" }); + await betaRow.click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + await expect(page.getByRole("heading", { name: "Tenant overrides" })).toBeVisible(); + })(); + + await step("Toggle tenant override & verify success toast")(async () => { + const overridesTable = page.getByRole("table", { name: "Tenant overrides" }); + await expect(overridesTable).toBeVisible(); + + const firstOverrideSwitch = overridesTable.getByRole("switch").first(); + await firstOverrideSwitch.click(); + + await expectToastMessage(context, "Tenant override updated"); + })(); + + await step("Set A/B rollout percentage & verify success toast")(async () => { + const percentageInput = page.getByRole("spinbutton", { name: "Rollout percentage" }); + await percentageInput.fill("50"); + await page.getByRole("button", { name: "Save" }).click(); + + await expectToastMessage(context, "Rollout percentage updated"); + })(); + + await step("Click back link & verify return to flag list page")(async () => { + await page.getByRole("link", { name: "Back to feature flags" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags`); + await expect(page.getByRole("heading", { name: "Feature flags" })).toBeVisible(); + })(); + + await backOfficeContext.close(); + }); +}); From 379107f1265afe08bd294d1632d84884922a7ae7 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 19:21:09 +0200 Subject: [PATCH 011/155] Fix rollout percentage off-by-one and add reserved bucket 0/100 semantics --- .../SetFeatureFlagRolloutPercentage.cs | 5 +- .../FeatureFlags/Domain/FeatureFlag.cs | 8 +-- .../FeatureFlagEvaluationService.cs | 8 ++- .../FeatureFlags/Queries/GetFlagTenants.cs | 8 ++- .../Tests/FeatureFlags/FeatureFlagTests.cs | 69 +++++++++++++++++-- .../FeatureFlags/RolloutBucketHasher.cs | 2 +- 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs index 68da98125..1f5c8effb 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs @@ -49,13 +49,14 @@ public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, } else if (command.RolloutPercentage == 100) { - bucketStart = 1; + bucketStart = 0; bucketEnd = 100; } else { + // Normal rollout uses buckets 1-99. Bucket 0 (always opt-in) and 100 (always opt-out) are reserved. bucketStart = RolloutBucketHasher.ComputeBucket(command.FlagKey); - bucketEnd = (bucketStart - 1 + command.RolloutPercentage) % 100 + 1; + bucketEnd = (bucketStart - 1 + command.RolloutPercentage - 1) % 99 + 1; } flag.SetRolloutRange(bucketStart, bucketEnd); diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index fa7a9a924..b6c95a73e 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -72,14 +72,14 @@ public void SetRolloutRange(int? bucketStart, int? bucketEnd) throw new ArgumentException("Bucket start and bucket end must both be set or both be null."); } - if (bucketStart is not null && (bucketStart < 1 || bucketStart > 100)) + if (bucketStart is not null && (bucketStart < 0 || bucketStart > 100)) { - throw new ArgumentOutOfRangeException(nameof(bucketStart), "Bucket start must be between 1 and 100."); + throw new ArgumentOutOfRangeException(nameof(bucketStart), "Bucket start must be between 0 and 100."); } - if (bucketEnd is not null && (bucketEnd < 1 || bucketEnd > 100)) + if (bucketEnd is not null && (bucketEnd < 0 || bucketEnd > 100)) { - throw new ArgumentOutOfRangeException(nameof(bucketEnd), "Bucket end must be between 1 and 100."); + throw new ArgumentOutOfRangeException(nameof(bucketEnd), "Bucket end must be between 0 and 100."); } BucketStart = bucketStart; diff --git a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs b/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs index 9ef0ae72c..ebf37ec5f 100644 --- a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs +++ b/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs @@ -85,12 +85,18 @@ private static bool IsActive(FeatureFlag flag) private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) { + // Bucket 0 = always opt-in (internal testers), included in any rollout + if (bucket == 0) return true; + + // Bucket 100 = always opt-out (VIP customers), only included at 100% rollout + if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; + if (bucketStart <= bucketEnd) { return bucket >= bucketStart && bucket <= bucketEnd; } - // Wrap-around case (e.g., start=90, end=10 means 90-100 and 1-10) + // Wrap-around case (e.g., start=90, end=10 means 90-99 and 1-10) return bucket >= bucketStart || bucket <= bucketEnd; } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs index d802de35b..322d06d31 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs @@ -68,12 +68,18 @@ public async Task> Handle(GetFlagTenantsQuery que private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) { + // Bucket 0 = always opt-in (internal testers), included in any rollout + if (bucket == 0) return true; + + // Bucket 100 = always opt-out (VIP customers), only included at 100% rollout + if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; + if (bucketStart <= bucketEnd) { return bucket >= bucketStart && bucket <= bucketEnd; } - // Wrap-around case (e.g., start=90, end=10 means 90-100 and 1-10) + // Wrap-around case (e.g., start=90, end=10 means 90-99 and 1-10) return bucket >= bucketStart || bucket <= bucketEnd; } } diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 01831adbd..77f70c576 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -317,6 +317,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenValidPercentage_ShouldUpda ); bucketStart.Should().NotBeNull(); bucketEnd.Should().NotBeNull(); + CountBucketsInRange((int)bucketStart.Value, (int)bucketEnd.Value).Should().Be(50); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); @@ -325,6 +326,32 @@ public async Task SetFeatureFlagRolloutPercentage_WhenValidPercentage_ShouldUpda TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } + [Theory] + [InlineData(1)] + [InlineData(99)] + public async Task SetFeatureFlagRolloutPercentage_WhenSetToNPercent_ShouldIncludeExactlyNBuckets(int percentage) + { + // Arrange + var flagKey = "beta-features"; + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = percentage }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var bucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var bucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + bucketStart.Should().NotBeNull(); + bucketEnd.Should().NotBeNull(); + CountBucketsInRange((int)bucketStart.Value, (int)bucketEnd.Value).Should().Be(percentage); + } + [Fact] public async Task SetFeatureFlagRolloutPercentage_WhenInvalidPercentage_ShouldFailValidation() { @@ -408,7 +435,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFu var bucketEnd = Connection.ExecuteScalar( "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - bucketStart.Should().Be(1); + bucketStart.Should().Be(0); bucketEnd.Should().Be(100); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); @@ -484,7 +511,7 @@ public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource( "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); Connection.Update("feature_flags", "id", baseRowId, [ - ("bucket_start", 1), + ("bucket_start", 0), ("bucket_end", 100) ] ); @@ -581,7 +608,7 @@ public void BucketRange_WhenNormalRange_ShouldMatchCorrectly() [Fact] public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() { - // Arrange & Act & Assert + // Arrange & Act & Assert (wrap-around within 1-99 range) IsInBucketRange(95, 90, 10).Should().BeTrue(); IsInBucketRange(5, 90, 10).Should().BeTrue(); IsInBucketRange(50, 90, 10).Should().BeFalse(); @@ -591,6 +618,26 @@ public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() IsInBucketRange(89, 90, 10).Should().BeFalse(); } + [Fact] + public void BucketRange_WhenBucketZero_ShouldAlwaysBeIncluded() + { + // Bucket 0 = always opt-in, included in any rollout range + IsInBucketRange(0, 1, 50).Should().BeTrue(); + IsInBucketRange(0, 50, 99).Should().BeTrue(); + IsInBucketRange(0, 90, 10).Should().BeTrue(); + IsInBucketRange(0, 0, 100).Should().BeTrue(); + } + + [Fact] + public void BucketRange_WhenBucketHundred_ShouldOnlyBeIncludedAtFullRollout() + { + // Bucket 100 = always opt-out, only included when range covers all (0-100 = 100% rollout) + IsInBucketRange(100, 0, 100).Should().BeTrue(); + IsInBucketRange(100, 1, 99).Should().BeFalse(); + IsInBucketRange(100, 1, 50).Should().BeFalse(); + IsInBucketRange(100, 90, 10).Should().BeFalse(); + } + [Fact] public void RolloutBucket_ShouldBeDeterministic() { @@ -603,7 +650,7 @@ public void RolloutBucket_ShouldBeDeterministic() // Assert bucket1.Should().Be(bucket2); - bucket1.Should().BeInRange(1, 100); + bucket1.Should().BeInRange(1, 99); } // JWT invalidation tests @@ -746,6 +793,9 @@ public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllT private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) { + if (bucket == 0) return true; + if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; + if (bucketStart <= bucketEnd) { return bucket >= bucketStart && bucket <= bucketEnd; @@ -753,4 +803,15 @@ private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) return bucket >= bucketStart || bucket <= bucketEnd; } + + private static int CountBucketsInRange(int bucketStart, int bucketEnd) + { + // For normal rollout (1-99 range), count only the normal buckets + if (bucketStart <= bucketEnd) + { + return bucketEnd - bucketStart + 1; + } + + return 99 - bucketStart + 1 + bucketEnd; + } } diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs index d1666cb26..7c3cf5ac2 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs @@ -16,7 +16,7 @@ public static int ComputeBucket(string entityId) hash *= FnvPrime; } - return (int)(hash % 100 + 1); + return (int)(hash % 99 + 1); } } } From b3a686d23c2b6faad401eebb96e32d453a4a5cfa Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 19:21:21 +0200 Subject: [PATCH 012/155] Add account settings and user preferences steps to feature flag E2E test --- .../AuthenticationCookieMiddleware.cs | 47 ++++++++++++++----- .../GetTenantConfigurableFeatureFlags.cs | 2 - .../GetUserConfigurableFeatureFlags.cs | 2 - .../Tests/FeatureFlags/FeatureFlagTests.cs | 28 +++++++++++ .../tests/e2e/feature-flag-flows.spec.ts | 46 ++++++++++++++++-- 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index ef6d72a23..e161af8c0 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -21,8 +21,10 @@ ILogger logger public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenCookieValue)) + string? refreshTokenCookieValue = null; + if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenFromCookie)) { + refreshTokenCookieValue = refreshTokenFromCookie; context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenCookieName, out var accessTokenCookieValue); await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenCookieValue, accessTokenCookieValue); } @@ -45,20 +47,43 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } - await next(context); - + var refreshHandled = false; - if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) + async Task HandleRefreshAsync() { - logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); - var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue!); - await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + if (refreshHandled) return; + refreshHandled = true; + + if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) + { + if (refreshTokenCookieValue is not null) + { + logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); + var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue); + await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); + } + + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && + context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) + { + await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); + } } - else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && - context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) + + // Register OnStarting BEFORE next(context). YARP streams responses back, so by the time control + // returns from next(context) the response headers have already been flushed and Set-Cookie can + // no longer be appended. OnStarting fires while headers are still mutable. The fallback call + // after next(context) handles direct invocation paths (e.g. unit tests) where OnStarting is + // never triggered because the response is never sent. + context.Response.OnStarting(HandleRefreshAsync); + + await next(context); + + if (!context.Response.HasStarted) { - await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); + await HandleRefreshAsync(); } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs index 861a9bf83..efd637ff0 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs @@ -27,11 +27,9 @@ public async Task> Handle(GetTena .ToArray(); var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, string.Empty, cancellationToken); - var baseRows = allRows.Where(r => r.TenantId is null && r.UserId is null).ToDictionary(r => r.FlagKey); var tenantOverrides = allRows.Where(r => r.TenantId == tenantId && r.UserId is null).ToDictionary(r => r.FlagKey); var flags = configurableDefinitions - .Where(definition => baseRows.TryGetValue(definition.Key, out var baseRow) && IsActive(baseRow)) .Select(definition => { tenantOverrides.TryGetValue(definition.Key, out var tenantOverride); diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs index 05873d4df..297a495df 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs @@ -28,11 +28,9 @@ public async Task> Handle(GetUserCo .ToArray(); var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, userId, cancellationToken); - var baseRows = allRows.Where(r => r.TenantId is null && r.UserId is null).ToDictionary(r => r.FlagKey); var userOverrides = allRows.Where(r => r.TenantId == tenantId && r.UserId == userId).ToDictionary(r => r.FlagKey); var flags = configurableDefinitions - .Where(definition => baseRows.TryGetValue(definition.Key, out var baseRow) && IsActive(baseRow)) .Select(definition => { userOverrides.TryGetValue(definition.Key, out var userOverride); diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 77f70c576..b537d9b56 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -443,6 +443,34 @@ public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFu TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } + // Tenant configurable flags query tests + + [Fact] + public async Task GetTenantConfigurableFlags_WhenCalled_ShouldReturnConfigurableFlagsWithCurrentOverrideState() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account/feature-flags/tenant-configurable"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Flags.Should().Contain(f => f.FlagKey == "custom-branding" && f.Enabled == false); + } + + [Fact] + public async Task GetUserConfigurableFlags_WhenCalled_ShouldReturnConfigurableUserFlagsWithCurrentOverrideState() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account/feature-flags/user-configurable"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Flags.Should().Contain(f => f.FlagKey == "compact-view" && f.Enabled == false); + } + // Flag tenants query tests [Fact] diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 55562dc91..734cfafd4 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -10,11 +10,17 @@ test.describe("@smoke", () => { /** * FEATURE FLAG SYSTEM E2E TEST * - * Tests the back-office feature flag management flow: - * - Flag list: view flags, filter by scope tabs, toggle a flag via switch - * - Flag detail: navigate into tenant-scoped flag, toggle tenant override, set A/B rollout percentage + * Tests the full feature flag management flow: + * - Back-office flag list: view flags, filter by scope tabs, toggle a flag via switch + * - Back-office flag detail: navigate into tenant-scoped flag, toggle tenant override, set A/B rollout percentage + * - Account settings: verify Features section, toggle tenant-scoped custom branding flag + * - User preferences: verify Beta features section, toggle user-scoped compact view flag */ - test("should manage feature flags across back-office flag list & detail views", async ({ browser }) => { + test("should manage feature flags across back-office, account settings & user preferences", async ({ + ownerPage, + browser + }) => { + const ownerContext = createTestContext(ownerPage); const backOfficeContext = await browser.newContext({ baseURL: BACK_OFFICE_BASE_URL, ignoreHTTPSErrors: true }); const page = await backOfficeContext.newPage(); const context = createTestContext(page); @@ -108,5 +114,37 @@ test.describe("@smoke", () => { })(); await backOfficeContext.close(); + + // === ACCOUNT SETTINGS: TENANT FEATURE FLAGS === + + await step("Navigate to account settings & verify Features section with tenant flags")(async () => { + await ownerPage.goto("/account/settings"); + + await expect(ownerPage.getByRole("heading", { name: "Features" })).toBeVisible(); + await expect(ownerPage.getByText("Custom branding")).toBeVisible(); + })(); + + await step("Toggle custom branding flag & verify success toast")(async () => { + const toggle = ownerPage.getByRole("switch", { name: "Custom branding" }); + await toggle.click(); + + await expectToastMessage(ownerContext, "Feature updated"); + })(); + + // === USER PREFERENCES: USER FEATURE FLAGS === + + await step("Navigate to user preferences & verify Beta features section with user flags")(async () => { + await ownerPage.goto("/user/preferences"); + + await expect(ownerPage.getByRole("heading", { name: "Beta features" })).toBeVisible(); + await expect(ownerPage.getByText("Compact view")).toBeVisible(); + })(); + + await step("Toggle compact view user flag & verify success toast")(async () => { + const toggle = ownerPage.getByRole("switch", { name: "Compact view" }); + await toggle.click(); + + await expectToastMessage(ownerContext, "Preference updated"); + })(); }); }); From da3863126610382af6ba25e5a3cd1edab6166302 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 19:45:24 +0200 Subject: [PATCH 013/155] Add CreatedAt timestamp to feature flag API response --- .../FeatureFlags/Queries/GetFeatureFlags.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs index 44c07b7d5..0015e5cda 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs @@ -21,6 +21,7 @@ public sealed record FeatureFlagInfo( bool IsAbTestEligible, bool ConfigurableByTenant, bool ConfigurableByUser, + DateTimeOffset? CreatedAt, DateTimeOffset? EnabledAt, DateTimeOffset? DisabledAt, int? BucketStart, @@ -46,12 +47,13 @@ public async Task> Handle(GetFeatureFlagsQuery r return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, - null, null, null, null, null, isSystemFlagActive + null, null, null, null, null, null, isSystemFlagActive ); } baseRowsByKey.TryGetValue(definition.Key, out var baseRow); + var createdAt = baseRow?.CreatedAt; var enabledAt = baseRow?.EnabledAt; var disabledAt = baseRow?.DisabledAt; var bucketStart = baseRow?.BucketStart; @@ -62,7 +64,7 @@ public async Task> Handle(GetFeatureFlagsQuery r return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, - enabledAt, disabledAt, bucketStart, bucketEnd, rolloutPercentage, isActive + createdAt, enabledAt, disabledAt, bucketStart, bucketEnd, rolloutPercentage, isActive ); } ).ToArray(); @@ -84,12 +86,15 @@ private bool IsSystemFlagEnabled(string flagKey) { if (bucketStart is null || bucketEnd is null) return null; + // 100% rollout uses reserved range 0-100 + if (bucketStart == 0 && bucketEnd == 100) return 100; + if (bucketStart <= bucketEnd) { return bucketEnd.Value - bucketStart.Value + 1; } - // Wrap-around case - return 100 - bucketStart.Value + 1 + bucketEnd.Value; + // Wrap-around case within 1-99 range + return 99 - bucketStart.Value + 1 + bucketEnd.Value; } } From 1889d292448dddfffbf32ede59955806ca9b09fa Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 19:47:49 +0200 Subject: [PATCH 014/155] Improve back-office flag detail page with search, sort, tenant ID, timestamps, and contextual toasts --- .../routes/feature-flags/$flagKey.tsx | 27 +- .../-components/TenantOverridesSection.tsx | 81 +++++- .../shared/translations/locale/da-DK.po | 239 ++++++------------ .../tests/e2e/feature-flag-flows.spec.ts | 2 +- 4 files changed, 177 insertions(+), 172 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 317cfbee0..8984a6e17 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -58,6 +58,7 @@ export default function FlagDetailPage() { {flag?.description ?? flagKey} } + subtitle={flag?.key} > {isLoading ? ( @@ -65,7 +66,11 @@ export default function FlagDetailPage() {
{flag.scope === "Tenant" && ( - + )}
) : null} @@ -111,6 +116,7 @@ function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { /> + {flag.isAbTestEligible && ( )} @@ -118,6 +124,25 @@ function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { ); } +function FlagTimestamps({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { + const timestamps = []; + if (flag.enabledAt) { + timestamps.push(`${t`Enabled`}: ${formatTimestamp(flag.enabledAt)}`); + } + if (flag.disabledAt) { + timestamps.push(`${t`Disabled`}: ${formatTimestamp(flag.disabledAt)}`); + } + + if (timestamps.length === 0) return null; + + return {timestamps.join(" | ")}; +} + +function formatTimestamp(isoDate: string): string { + const date = new Date(isoDate); + return new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "short", day: "numeric" }).format(date); +} + function RolloutPercentageInput({ flagKey, currentPercentage diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 14d829231..fc56dde31 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -3,33 +3,91 @@ import { Trans } from "@lingui/react/macro"; import { Badge } from "@repo/ui/components/Badge"; import { Switch } from "@repo/ui/components/Switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TextField } from "@repo/ui/components/TextField"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { api } from "@/shared/lib/api/client"; import type { FlagTenantInfo } from "./types"; +type SortColumn = "tenantName" | "isEnabled"; +type SortDirection = "ascending" | "descending"; + export function TenantOverridesSection({ flagKey, + flagDescription, tenants }: Readonly<{ flagKey: string; + flagDescription: string; tenants: FlagTenantInfo[]; }>) { + const [search, setSearch] = useState(""); + const [sortColumn, setSortColumn] = useState("tenantName"); + const [sortDirection, setSortDirection] = useState("ascending"); + + const filteredAndSortedTenants = useMemo(() => { + const lowerSearch = search.toLowerCase(); + const filtered = search + ? tenants.filter( + (tenant) => + tenant.tenantName.toLowerCase().includes(lowerSearch) || String(tenant.tenantId).includes(lowerSearch) + ) + : tenants; + + return [...filtered].sort((a, b) => { + const direction = sortDirection === "ascending" ? 1 : -1; + if (sortColumn === "tenantName") { + return a.tenantName.localeCompare(b.tenantName) * direction; + } + return (Number(b.isEnabled) - Number(a.isEnabled)) * direction; + }); + }, [tenants, search, sortColumn, sortDirection]); + + const handleSortChange = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection((prev) => (prev === "ascending" ? "descending" : "ascending")); + } else { + setSortColumn(column); + setSortDirection("ascending"); + } + }; + return (

Tenant overrides

+ setSearch(value)} + className="max-w-[20rem]" + />
+ Tenant ID + + handleSortChange("tenantName")} + aria-sort={sortColumn === "tenantName" ? sortDirection : undefined} + > Tenant + {sortColumn === "tenantName" && } - + handleSortChange("isEnabled")} + aria-sort={sortColumn === "isEnabled" ? sortDirection : undefined} + > Status + {sortColumn === "isEnabled" && } Source @@ -40,8 +98,13 @@ export function TenantOverridesSection({ - {tenants.map((tenant) => ( - + {filteredAndSortedTenants.map((tenant) => ( + ))}
@@ -50,11 +113,17 @@ export function TenantOverridesSection({ ); } +function SortIndicator({ direction }: Readonly<{ direction: SortDirection }>) { + return {direction === "ascending" ? "\u25B2" : "\u25BC"}; +} + function TenantOverrideRow({ flagKey, + flagDescription, tenant }: Readonly<{ flagKey: string; + flagDescription: string; tenant: FlagTenantInfo; }>) { const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); @@ -67,7 +136,10 @@ function TenantOverrideRow({ }, { onSuccess: () => { - toast.success(t`Tenant override updated`); + const message = checked + ? t`${flagDescription} enabled for ${tenant.tenantName}` + : t`${flagDescription} disabled for ${tenant.tenantName}`; + toast.success(message); } } ); @@ -77,6 +149,7 @@ function TenantOverrideRow({ return ( + {tenant.tenantId} {tenant.tenantName} {tenant.isEnabled ? t`Enabled` : t`Disabled`} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index c4ba5ac95..6ad2bd73b 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -21,11 +21,6 @@ msgstr "{0, plural, one {# time siden} other {# timer siden}}" msgid "{0, plural, one {# minute ago} other {# minutes ago}}" msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" -#. placeholder {0}: group.plan -#. placeholder {1}: group.tenants.length -msgid "{0} ({1})" -msgstr "" - #. placeholder {0}: formatCurrency(blended, currency) msgid "{0} blended" msgstr "{0} samlet" @@ -47,14 +42,12 @@ msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" #. placeholder {0}: tenant.tenantName -#. placeholder {0}: user.email -msgid "{featureFlagDescription} disabled for {0}" -msgstr "" +msgid "{flagDescription} disabled for {0}" +msgstr "{flagDescription} deaktiveret for {0}" #. placeholder {0}: tenant.tenantName -#. placeholder {0}: user.email -msgid "{featureFlagDescription} enabled for {0}" -msgstr "" +msgid "{flagDescription} enabled for {0}" +msgstr "{flagDescription} aktiveret for {0}" msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inaktive" @@ -111,9 +104,6 @@ msgstr "Konto" msgid "Account actions" msgstr "" -msgid "Account flags" -msgstr "" - msgid "Account growth" msgstr "Kontovækst" @@ -128,9 +118,6 @@ msgstr "Konto-ID" msgid "Account preview" msgstr "Kontoforhåndsvisning" -msgid "Account status" -msgstr "" - msgid "Account users" msgstr "Kontobrugere" @@ -140,12 +127,6 @@ msgstr "konti" msgid "Accounts" msgstr "Konti" -msgid "Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are available for plan-managed flags." -msgstr "" - -msgid "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." -msgstr "" - msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." @@ -176,7 +157,7 @@ msgstr "Alle hændelsestyper" msgid "All statuses" msgstr "Alle statusser" -msgid "All users across every account, most recently seen first. Search and filter to narrow down." +msgid "All users across every account, newest first. Search and filter to narrow down." msgstr "" msgid "All-time" @@ -195,8 +176,8 @@ msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." #. placeholder {0}: result.billingEventsAppended -#. placeholder {1}: formatDate(result.reconciledAt) -msgid "Appended {0} new billing events. Last reconciled at {1}." +#. placeholder {1}: formatDate(result.syncedAt) +msgid "Appended {0} new billing events. Last synced at {1}." msgstr "" msgid "Authoritative log of subscription, payment, and billing transitions across all accounts." @@ -217,12 +198,6 @@ msgstr "Back Office oversigt · {today}" msgid "Basis" msgstr "Basis" -msgid "Beta features" -msgstr "" - -msgid "Billing" -msgstr "" - msgid "Billing address" msgstr "Faktureringsadresse" @@ -244,12 +219,6 @@ msgstr "Samlet MRR" msgid "Browser" msgstr "Browser" -msgid "Bucket" -msgstr "" - -msgid "Cancel" -msgstr "" - msgid "Canceled" msgstr "Opsagt" @@ -289,8 +258,8 @@ msgstr "" msgid "Close account preview" msgstr "Luk kontoforhåndsvisning" -msgid "Compact view" -msgstr "" +msgid "Coming soon" +msgstr "Kommer snart" msgid "Contact your administrator." msgstr "Kontakt din administrator." @@ -304,7 +273,7 @@ msgstr "Oprettet" #. placeholder {0}: formatDate(user.createdAt, false, false, true) #. placeholder {1}: formatDate(user.createdAt) msgid "Created <0>{0}<1>{1}" -msgstr "Oprettet <0>{0}<1>{1}" +msgstr "" msgid "Credit note" msgstr "Kreditnota" @@ -367,12 +336,6 @@ msgstr "Nedgraderer" msgid "Drift detected" msgstr "Afvigelser fundet" -msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." -msgstr "" - -msgid "Early access to experimental features before general availability" -msgstr "" - msgid "Email" msgstr "E-mail" @@ -385,20 +348,6 @@ msgstr "E-mail afventer" msgid "Enabled" msgstr "" -#. placeholder {0}: enabledTenants.length -#. placeholder {0}: enabledUsers.length -msgid "Enabled ({0})" -msgstr "" - -#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) -#. placeholder {1}: formatTimestamp(featureFlag.disabledAt) -msgid "Enabled period: {0} - {1}" -msgstr "" - -#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) -msgid "Enabled: {0}" -msgstr "" - msgid "Enter kiosk mode" msgstr "" @@ -411,24 +360,15 @@ msgstr "Hændelsesvisning" msgid "Every invoice, refund, and credit note — the money in and out for this subscription." msgstr "" -msgid "Every invoice, refund, and credit note across all accounts." -msgstr "" - msgid "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." msgstr "" msgid "Exit kiosk mode" msgstr "" -msgid "Experimental UI" -msgstr "" - msgid "Expired" msgstr "" -msgid "Expires" -msgstr "" - msgid "Failed" msgstr "Mislykket" @@ -438,9 +378,18 @@ msgstr "" msgid "Feature flag deactivated" msgstr "" +msgid "Feature flag detail" +msgstr "" + msgid "Feature flags" msgstr "Feature flags" +msgid "Filter by flag type" +msgstr "" + +msgid "Flag name" +msgstr "" + msgid "Free" msgstr "Gratis" @@ -450,9 +399,6 @@ msgstr "Gå til forsiden" msgid "Google" msgstr "Google" -msgid "Google OAuth" -msgstr "" - msgid "Hide details" msgstr "Skjul detaljer" @@ -468,9 +414,6 @@ msgstr "Fakturavisning" msgid "Invoices" msgstr "" -msgid "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." -msgstr "" - msgid "IP address" msgstr "IP-adresse" @@ -570,9 +513,6 @@ msgstr "MRR-tendens" msgid "Name" msgstr "Navn" -msgid "Name:" -msgstr "" - msgid "Navigation" msgstr "Navigation" @@ -588,9 +528,6 @@ msgstr "Næste" msgid "No account memberships" msgstr "Ingen kontomedlemskaber" -msgid "No accounts in this group." -msgstr "" - msgid "No accounts match your filters" msgstr "Ingen konti matcher dine filtre" @@ -648,12 +585,6 @@ msgstr "Intet abonnement" msgid "No recent billing events" msgstr "" -msgid "No recent logins" -msgstr "" - -msgid "No recent payments" -msgstr "" - msgid "No recent signups" msgstr "Ingen nylige tilmeldinger" @@ -681,9 +612,6 @@ msgstr "" msgid "No users" msgstr "Ingen brugere" -msgid "No users found" -msgstr "" - msgid "No users match your filters." msgstr "Ingen brugere matcher dine filtre." @@ -711,9 +639,6 @@ msgstr "Åbn konto" msgid "Open credit note" msgstr "Åbn kreditnota" -msgid "Open in Stripe" -msgstr "" - msgid "Open invoice" msgstr "Åbn faktura" @@ -730,15 +655,9 @@ msgid "Override" msgstr "Tilsidesættelse" #. placeholder {0}: tenant.tenantName -#. placeholder {0}: user.email msgid "Override for {0}" msgstr "Tilsidesættelse for {0}" -#. placeholder {0}: tenant.tenantName -#. placeholder {0}: user.email -msgid "Override removed for {0}" -msgstr "Tilsidesættelse fjernet for {0}" - msgid "Overview" msgstr "Overblik" @@ -760,9 +679,6 @@ msgstr "Forfalden" msgid "Payment failed" msgstr "Betaling mislykkedes" -msgid "Payment method" -msgstr "" - msgid "Payment method updated" msgstr "" @@ -793,15 +709,9 @@ msgstr "" msgid "Plan distribution" msgstr "Plan-fordeling" -msgid "Plan flags" -msgstr "" - msgid "Plan transition" msgstr "" -msgid "Platform" -msgstr "" - msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -826,23 +736,17 @@ msgstr "" msgid "Recent billing events" msgstr "" -msgid "Recent logins" -msgstr "" - -msgid "Recent payments" -msgstr "" - msgid "Recent signups" msgstr "Nylige tilmeldinger" msgid "Reconcile" -msgstr "" +msgstr "Afstem" msgid "Reconcile complete" -msgstr "" +msgstr "Afstemning fuldført" msgid "Reconcile complete with drift detected" -msgstr "" +msgstr "Afstemning fuldført med afvigelser" #. placeholder {0}: archivedAwaiting.count #. placeholder {1}: formatDate(archivedAwaiting.oldestOccurredAt) @@ -854,16 +758,16 @@ msgid "Reconcile rebuilds this tenant's subscription and billing events from Str msgstr "Afstemning genopbygger denne kontos abonnement og faktureringshændelser direkte fra Stripe via events.list-API'et (de seneste 30 dage). Hvis afvigelserne ikke er ryddet bagefter, er katastrofegendannelse fra de lokalt arkiverede Stripe-payloads tilgængelig som sidste udvej." msgid "Reconcile with Stripe" -msgstr "" +msgstr "Afstem med Stripe" msgid "Reconcile with Stripe?" -msgstr "" +msgstr "Afstem med Stripe?" msgid "Reconciling..." -msgstr "" +msgstr "Afstemmer..." msgid "Reduce spacing between UI elements for a denser layout" -msgstr "" +msgstr "Reducer afstanden mellem UI-elementer for et tættere layout" msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Posteringer vises her, når konti abonnerer, og Stripe-webhooks behandles." @@ -934,12 +838,21 @@ msgstr "Udrulningsspande: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPerce msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" msgstr "Udrulningsspande: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgid "Rollout percentage" +msgstr "Udrulningsprocent" + msgid "Rollout percentage updated" msgstr "Udrulningsprocent opdateret" msgid "Run disaster recovery" msgstr "Kør katastrofegendannelse" +msgid "Save" +msgstr "" + +msgid "Saving..." +msgstr "" + msgid "Scheduled plan change" msgstr "Planlagt planændring" @@ -952,12 +865,6 @@ msgstr "Søg" msgid "Search by account name" msgstr "" -msgid "Search by account name or ID" -msgstr "" - -msgid "Search by email" -msgstr "" - msgid "Search by email, name, or account" msgstr "Søg på e-mail, navn eller konto" @@ -967,14 +874,8 @@ msgstr "Søg efter navn" msgid "Search by name or email" msgstr "Søg efter navn eller e-mail" -msgid "Search for users" -msgstr "" - -msgid "Search for users by email and toggle the override switch to enable this feature." -msgstr "" - -msgid "Search results" -msgstr "" +msgid "Search by tenant name or ID" +msgstr "Søg efter lejernavn eller ID" msgid "Search users" msgstr "Søg brugere" @@ -988,9 +889,6 @@ msgstr "Sessioner" msgid "Show details" msgstr "Vis detaljer" -msgid "Sign in with Google using OpenID Connect" -msgstr "" - msgid "Signed up" msgstr "Tilmeldt" @@ -1024,9 +922,6 @@ msgstr "Standard" msgid "Status" msgstr "Status" -msgid "Stripe-powered subscription billing and plan management" -msgstr "" - msgid "Subscribed" msgstr "Tilmeldt" @@ -1042,9 +937,6 @@ msgstr "" msgid "Subscription, payment, and billing transitions will appear here." msgstr "" -msgid "Subscriptions" -msgstr "" - msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." @@ -1054,8 +946,11 @@ msgstr "Subtotal" msgid "Succeeded" msgstr "Gennemført" -msgid "Successful logins will appear here as users sign in." -msgstr "" +msgid "Support" +msgstr "Support" + +msgid "Support (coming soon)" +msgstr "Support (kommer snart)" msgid "Successful, pending, and failed invoices across all accounts." msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle konti." @@ -1063,15 +958,33 @@ msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle kont msgid "Suspended" msgstr "Suspenderet" -msgid "System" -msgstr "System" +msgid "Sync complete" +msgstr "" -msgid "System flags" +msgid "Sync complete with drift detected" msgstr "" +msgid "Sync with Stripe" +msgstr "" + +msgid "Syncing..." +msgstr "" + +msgid "System" +msgstr "System" + msgid "Tablet" msgstr "Tablet" +msgid "Tenant" +msgstr "" + +msgid "Tenant ID" +msgstr "Lejer-ID" + +msgid "Tenant overrides" +msgstr "" + msgid "The page you are looking for does not exist or was moved." msgstr "Siden du leder efter findes ikke eller er blevet flyttet." @@ -1099,13 +1012,10 @@ msgstr "Denne bruger har ingen registrerede sessioner." msgid "This user is not a member of any account." msgstr "Denne bruger er ikke medlem af nogen konto." -#. placeholder {0}: getFeatureFlagName(featureFlag.key) +#. placeholder {0}: flag.description msgid "Toggle {0}" msgstr "" -msgid "Toggle the override switch to enable this feature for specific accounts." -msgstr "" - msgid "Total" msgstr "" @@ -1118,9 +1028,6 @@ msgstr "Omsætning i alt" msgid "Try a different search term or clear the role and activity filters." msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." -msgid "Try adjusting your search" -msgstr "" - msgid "Try again" msgstr "Prøv igen" @@ -1133,6 +1040,9 @@ msgstr "Prøv at rydde søgningen for at se flere resultater." msgid "Try out experimental user interface components" msgstr "Afprøv eksperimentelle brugergrænsefladekomponenter" +msgid "Type" +msgstr "Type" + msgid "Type an email address above to find users and manage their overrides" msgstr "Indtast en e-mailadresse ovenfor for at finde brugere og administrere deres tilsidesættelser" @@ -1151,27 +1061,18 @@ msgstr "Bruger" msgid "User detail" msgstr "Brugerdetalje" -msgid "User flags" -msgstr "" - msgid "User logins / day" msgstr "Brugerlogins / dag" msgid "User menu" msgstr "Brugermenu" -msgid "User status" -msgstr "" - msgid "Users" msgstr "Brugere" msgid "Users active" msgstr "Aktive brugere" -msgid "Users are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific users." -msgstr "" - msgid "Users will appear here as accounts are created." msgstr "" @@ -1199,6 +1100,12 @@ msgstr "" msgid "vs prior period" msgstr "mod forrige periode" +msgid "Wait list" +msgstr "Venteliste" + +msgid "Wait list (coming soon)" +msgstr "Venteliste (kommer snart)" + msgid "When" msgstr "Hvornår" diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 734cfafd4..3dcf837ec 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -95,7 +95,7 @@ test.describe("@smoke", () => { const firstOverrideSwitch = overridesTable.getByRole("switch").first(); await firstOverrideSwitch.click(); - await expectToastMessage(context, "Tenant override updated"); + await expectToastMessage(context, "beta features for tenants"); })(); await step("Set A/B rollout percentage & verify success toast")(async () => { From a33a661f342d39385bc9fbca4d2bd146cc35572a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 21:48:07 +0200 Subject: [PATCH 015/155] Fix tenant override switch not updating after mutation --- .../feature-flags/-components/TenantOverridesSection.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index fc56dde31..bd6643093 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -7,7 +7,7 @@ import { TextField } from "@repo/ui/components/TextField"; import { useMemo, useState } from "react"; import { toast } from "sonner"; -import { api } from "@/shared/lib/api/client"; +import { api, queryClient } from "@/shared/lib/api/client"; import type { FlagTenantInfo } from "./types"; @@ -136,6 +136,7 @@ function TenantOverrideRow({ }, { onSuccess: () => { + queryClient.invalidateQueries(); const message = checked ? t`${flagDescription} enabled for ${tenant.tenantName}` : t`${flagDescription} disabled for ${tenant.tenantName}`; From 9bdb5b14b58904b4f6f9eb63bc7927cc21d8d04e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 21:50:07 +0200 Subject: [PATCH 016/155] Use targeted query invalidation with await for tenant override refresh --- .../-components/TenantOverridesSection.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index bd6643093..69a3884d0 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -4,7 +4,7 @@ import { Badge } from "@repo/ui/components/Badge"; import { Switch } from "@repo/ui/components/Switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { api, queryClient } from "@/shared/lib/api/client"; @@ -126,9 +126,17 @@ function TenantOverrideRow({ flagDescription: string; tenant: FlagTenantInfo; }>) { + const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + useEffect(() => { + if (!overrideMutation.isPending) { + setOptimisticEnabled(tenant.isEnabled); + } + }, [tenant.isEnabled, overrideMutation.isPending]); + const handleToggle = (checked: boolean) => { + setOptimisticEnabled(checked); overrideMutation.mutate( { params: { path: { flagKey } }, @@ -136,11 +144,16 @@ function TenantOverrideRow({ }, { onSuccess: () => { - queryClient.invalidateQueries(); + queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] + }); const message = checked ? t`${flagDescription} enabled for ${tenant.tenantName}` : t`${flagDescription} disabled for ${tenant.tenantName}`; toast.success(message); + }, + onError: () => { + setOptimisticEnabled(tenant.isEnabled); } } ); @@ -153,14 +166,16 @@ function TenantOverrideRow({ {tenant.tenantId} {tenant.tenantName} - {tenant.isEnabled ? t`Enabled` : t`Disabled`} + + {optimisticEnabled ? t`Enabled` : t`Disabled`} + {sourceLabel} Date: Fri, 3 Apr 2026 22:02:16 +0200 Subject: [PATCH 017/155] Fix tenant ID precision loss by using strongly-typed TenantId in feature flag responses --- .../Core/Features/FeatureFlags/Queries/GetFlagTenants.cs | 9 +++++---- .../account/Tests/FeatureFlags/FeatureFlagTests.cs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs index 322d06d31..86fb8807b 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs @@ -3,6 +3,7 @@ using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; +using SharedKernel.Domain; using SharedKernel.FeatureFlags; namespace Account.Features.FeatureFlags.Queries; @@ -18,7 +19,7 @@ public sealed record GetFlagTenantsQuery : IRequest { @@ -50,16 +51,16 @@ public async Task> Handle(GetFlagTenantsQuery que if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) { var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); - return new FlagTenantInfo(tenant.Id.Value, tenant.Name, isEnabled, "manual_override"); + return new FlagTenantInfo(tenant.Id, tenant.Name, isEnabled, "manual_override"); } if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { var isInRange = IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FlagTenantInfo(tenant.Id.Value, tenant.Name, isInRange, "ab_rollout"); + return new FlagTenantInfo(tenant.Id, tenant.Name, isInRange, "ab_rollout"); } - return new FlagTenantInfo(tenant.Id.Value, tenant.Name, false, "default"); + return new FlagTenantInfo(tenant.Id, tenant.Name, false, "default"); } ).ToArray(); diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index b537d9b56..54a1a37c6 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -525,7 +525,7 @@ public async Task GetFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrid response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - var tenantResult = result.Tenants.Single(t => t.TenantId == tenantId); + var tenantResult = result.Tenants.Single(t => t.TenantId.Value == tenantId); tenantResult.IsEnabled.Should().BeTrue(); tenantResult.Source.Should().Be("manual_override"); } From 3ad6577fe81c08b4cbb29afb12c108b277afbdd9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 22:03:19 +0200 Subject: [PATCH 018/155] Update frontend types for string-serialized tenant IDs --- .../feature-flags/-components/TenantOverridesSection.tsx | 5 ++--- .../BackOffice/routes/feature-flags/-components/types.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 69a3884d0..6495c5e96 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -31,8 +31,7 @@ export function TenantOverridesSection({ const lowerSearch = search.toLowerCase(); const filtered = search ? tenants.filter( - (tenant) => - tenant.tenantName.toLowerCase().includes(lowerSearch) || String(tenant.tenantId).includes(lowerSearch) + (tenant) => tenant.tenantName.toLowerCase().includes(lowerSearch) || tenant.tenantId.includes(lowerSearch) ) : tenants; @@ -140,7 +139,7 @@ function TenantOverrideRow({ overrideMutation.mutate( { params: { path: { flagKey } }, - body: { tenantId: tenant.tenantId, enabled: checked } + body: { tenantId: Number(tenant.tenantId), enabled: checked } }, { onSuccess: () => { diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 2a4c2dae2..2791abf25 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -21,7 +21,7 @@ export interface GetFeatureFlagsResponse { } export interface FlagTenantInfo { - tenantId: number; + tenantId: string; tenantName: string; isEnabled: boolean; source: "manual_override" | "ab_rollout" | "default"; From d494bd6c223fa28e2884d25d73005949a468ec9a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 22:42:41 +0200 Subject: [PATCH 019/155] Add comprehensive feature flag E2E test with tenant override and settings verification --- .../tests/e2e/feature-flag-flows.spec.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 3dcf837ec..bbbdca211 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -12,7 +12,7 @@ test.describe("@smoke", () => { * * Tests the full feature flag management flow: * - Back-office flag list: view flags, filter by scope tabs, toggle a flag via switch - * - Back-office flag detail: navigate into tenant-scoped flag, toggle tenant override, set A/B rollout percentage + * - Back-office flag detail: navigate into tenant-scoped flag, toggle tenant override twice, set A/B rollout percentage * - Account settings: verify Features section, toggle tenant-scoped custom branding flag * - User preferences: verify Beta features section, toggle user-scoped compact view flag */ @@ -88,12 +88,22 @@ test.describe("@smoke", () => { await expect(page.getByRole("heading", { name: "Tenant overrides" })).toBeVisible(); })(); - await step("Toggle tenant override & verify success toast")(async () => { + await step("Toggle tenant override & verify toast confirms state change")(async () => { const overridesTable = page.getByRole("table", { name: "Tenant overrides" }); await expect(overridesTable).toBeVisible(); - const firstOverrideSwitch = overridesTable.getByRole("switch").first(); - await firstOverrideSwitch.click(); + const firstRow = overridesTable.locator("tbody tr").first(); + const overrideSwitch = firstRow.getByRole("switch"); + await overrideSwitch.click(); + + await expectToastMessage(context, "beta features for tenants"); + })(); + + await step("Toggle tenant override back & verify toast confirms state change")(async () => { + const overridesTable = page.getByRole("table", { name: "Tenant overrides" }); + const firstRow = overridesTable.locator("tbody tr").first(); + const overrideSwitch = firstRow.getByRole("switch"); + await overrideSwitch.click(); await expectToastMessage(context, "beta features for tenants"); })(); From 97386da89e5f3fba25cd266801496f95e5288cb4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 22:56:22 +0200 Subject: [PATCH 020/155] Fix disabling tenant override for A/B rollout-enabled tenants --- .../Commands/SetTenantFeatureFlagInternal.cs | 11 ++- .../Tests/FeatureFlags/FeatureFlagTests.cs | 75 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 532d21c79..3786b95bb 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -58,12 +58,19 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca else { var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); - if (tenantOverride is not null) + if (tenantOverride is null) + { + tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId); + tenantOverride.Deactivate(now); + await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); + } + else { tenantOverride.Deactivate(now); featureFlagRepository.Update(tenantOverride); - events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); } + + events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); } var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 54a1a37c6..52c9d1490 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -167,6 +167,36 @@ public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideR TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } + [Fact] + public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverride_ShouldCreateDisabledOverrideRow() + { + // Arrange - tenant has no override row (enabled via A/B rollout or default) + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = false }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId }] + ); + rowCount.Should().Be(1); + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId }] + ); + disabledAt.Should().NotBeNullOrEmpty(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideRemoved"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + [Fact] public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_ShouldSucceed() { @@ -559,6 +589,51 @@ public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource( ); } + [Fact] + public async Task GetFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActive_ShouldReturnManualOverrideDisabled() + { + // Arrange - set up A/B rollout at 100% so all tenants are enabled + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 100) + ] + ); + + // Create a disabled override for the tenant (simulating admin toggling OFF) + var overrideId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId), + ("user_id", null), + ("enabled_at", null), + ("disabled_at", TimeProvider.GetUtcNow()), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false) + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + + // Assert - manual override should take precedence over A/B rollout + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + var tenantResult = result.Tenants.Single(t => t.TenantId.Value == tenantId); + tenantResult.IsEnabled.Should().BeFalse(); + tenantResult.Source.Should().Be("manual_override"); + } + [Fact] public async Task GetFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() { From 756305fc2c59deb7e5fa092430e0e92977bffc8e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 23:14:51 +0200 Subject: [PATCH 021/155] Add endpoint to remove tenant feature flag override --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 4 ++ .../Api/Endpoints/FeatureFlagEndpoints.cs | 4 ++ .../RemoveTenantFeatureFlagOverride.cs | 53 ++++++++++++++++++ .../Tests/FeatureFlags/FeatureFlagTests.cs | 56 +++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index dd9bb792b..88ad0256d 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -46,5 +46,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); + + group.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, long tenantId, IMediator mediator) + => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) + ).DisableAntiforgery(); } } diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 95350a24c..53274871d 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -37,6 +37,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); + internalGroup.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, long tenantId, IMediator mediator) + => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) + ).DisableAntiforgery(); + // Authenticated API endpoints (tenant owner and user operations) var group = routes.MapGroup("/api/account/feature-flags").WithTags("FeatureFlags").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs new file mode 100644 index 000000000..ad0e9853d --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -0,0 +1,53 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record RemoveTenantFeatureFlagOverrideCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes from API contract + public string FlagKey { get; init; } = null!; + + public required long TenantId { get; init; } +} + +public sealed class RemoveTenantFeatureFlagOverrideValidator : AbstractValidator +{ + public RemoveTenantFeatureFlagOverrideValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + } +} + +public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(RemoveTenantFeatureFlagOverrideCommand command, CancellationToken cancellationToken) + { + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + if (tenantOverride is null) return Result.NotFound($"No tenant override found for flag '{command.FlagKey}' and tenant '{command.TenantId}'."); + + featureFlagRepository.Remove(tenantOverride); + + var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + if (tenant is not null) + { + tenant.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + } + + events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); + + return Result.Success(); + } +} diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 52c9d1490..fe05567fc 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -216,6 +216,62 @@ public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_Shou TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } + // Remove tenant override tests (internal API) + + [Fact] + public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDeleteRow() + { + // Arrange - create an override row first + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var overrideId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId), + ("user_id", null), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false) + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId }] + ); + rowCount.Should().Be(0); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideRemoved"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task RemoveTenantFeatureFlagOverride_WhenNoOverrideExists_ShouldReturnNotFound() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + // Tenant override tests (owner API) [Fact] From 06df369dc91aac3f09dc8b90fa34ed7607fe1230 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 3 Apr 2026 23:15:51 +0200 Subject: [PATCH 022/155] Add remove override button for manual tenant overrides --- .../-components/TenantOverrideRow.tsx | 135 ++++++++++++++++++ .../-components/TenantOverridesSection.tsx | 92 +----------- .../shared/translations/locale/da-DK.po | 5 +- 3 files changed, 143 insertions(+), 89 deletions(-) create mode 100644 application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx new file mode 100644 index 000000000..e3ca3aa10 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -0,0 +1,135 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { Switch } from "@repo/ui/components/Switch"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { api, queryClient } from "@/shared/lib/api/client"; + +import type { FlagTenantInfo } from "./types"; + +function getSourceLabel(source: string): string { + switch (source) { + case "manual_override": + return t`Manual override`; + case "ab_rollout": + return t`A/B rollout`; + case "default": + return t`Default`; + default: + return source; + } +} + +export function TenantOverrideRow({ + flagKey, + flagDescription, + tenant +}: Readonly<{ + flagKey: string; + flagDescription: string; + tenant: FlagTenantInfo; +}>) { + const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); + const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + const removeMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + + useEffect(() => { + if (!overrideMutation.isPending) { + setOptimisticEnabled(tenant.isEnabled); + } + }, [tenant.isEnabled, overrideMutation.isPending]); + + const handleToggle = (checked: boolean) => { + setOptimisticEnabled(checked); + overrideMutation.mutate( + { + params: { path: { flagKey } }, + body: { tenantId: Number(tenant.tenantId), enabled: checked } + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] + }); + const message = checked + ? t`${flagDescription} enabled for ${tenant.tenantName}` + : t`${flagDescription} disabled for ${tenant.tenantName}`; + toast.success(message); + }, + onError: () => { + setOptimisticEnabled(tenant.isEnabled); + } + } + ); + }; + + const handleRemoveOverride = () => { + removeMutation.mutate( + { + params: { path: { flagKey }, query: { tenantId: Number(tenant.tenantId) } } + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] + }); + toast.success(t`Override removed for ${tenant.tenantName}`); + } + } + ); + }; + + const isPending = overrideMutation.isPending || removeMutation.isPending; + + return ( + + {tenant.tenantId} + {tenant.tenantName} + + + {optimisticEnabled ? t`Enabled` : t`Disabled`} + + + + {getSourceLabel(tenant.source)} + + +
+ {tenant.source === "manual_override" && ( + + + } + > + + + + Remove override + + + )} + +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 6495c5e96..31120933d 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -1,16 +1,13 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Badge } from "@repo/ui/components/Badge"; -import { Switch } from "@repo/ui/components/Switch"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; - -import { api, queryClient } from "@/shared/lib/api/client"; +import { useMemo, useState } from "react"; import type { FlagTenantInfo } from "./types"; +import { TenantOverrideRow } from "./TenantOverrideRow"; + type SortColumn = "tenantName" | "isEnabled"; type SortDirection = "ascending" | "descending"; @@ -115,84 +112,3 @@ export function TenantOverridesSection({ function SortIndicator({ direction }: Readonly<{ direction: SortDirection }>) { return {direction === "ascending" ? "\u25B2" : "\u25BC"}; } - -function TenantOverrideRow({ - flagKey, - flagDescription, - tenant -}: Readonly<{ - flagKey: string; - flagDescription: string; - tenant: FlagTenantInfo; -}>) { - const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); - const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); - - useEffect(() => { - if (!overrideMutation.isPending) { - setOptimisticEnabled(tenant.isEnabled); - } - }, [tenant.isEnabled, overrideMutation.isPending]); - - const handleToggle = (checked: boolean) => { - setOptimisticEnabled(checked); - overrideMutation.mutate( - { - params: { path: { flagKey } }, - body: { tenantId: Number(tenant.tenantId), enabled: checked } - }, - { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] - }); - const message = checked - ? t`${flagDescription} enabled for ${tenant.tenantName}` - : t`${flagDescription} disabled for ${tenant.tenantName}`; - toast.success(message); - }, - onError: () => { - setOptimisticEnabled(tenant.isEnabled); - } - } - ); - }; - - const sourceLabel = getSourceLabel(tenant.source); - - return ( - - {tenant.tenantId} - {tenant.tenantName} - - - {optimisticEnabled ? t`Enabled` : t`Disabled`} - - - - {sourceLabel} - - - - - - ); -} - -function getSourceLabel(source: string): string { - switch (source) { - case "manual_override": - return t`Manual override`; - case "ab_rollout": - return t`A/B rollout`; - case "default": - return t`Default`; - default: - return source; - } -} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 6ad2bd73b..97bec0232 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -658,6 +658,10 @@ msgstr "Tilsidesættelse" msgid "Override for {0}" msgstr "Tilsidesættelse for {0}" +#. placeholder {0}: tenant.tenantName +msgid "Override removed for {0}" +msgstr "Tilsidesættelse fjernet for {0}" + msgid "Overview" msgstr "Overblik" @@ -785,7 +789,6 @@ msgid "Remove override" msgstr "Fjern tilsidesættelse" #. placeholder {0}: tenant.tenantName -#. placeholder {0}: user.email msgid "Remove override for {0}" msgstr "Fjern tilsidesættelse for {0}" From 5f5cfb7185585637fb4e4b6b158f68b6d48b484d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 11:43:11 +0200 Subject: [PATCH 023/155] Add user-scoped feature flag endpoints and rollout bucket info --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 12 ++ .../Api/Endpoints/FeatureFlagEndpoints.cs | 12 ++ .../Commands/RemoveUserFeatureFlagOverride.cs | 55 +++++++ .../Commands/SetUserFeatureFlagInternal.cs | 87 ++++++++++ .../Domain/FeatureFlagRepository.cs | 9 ++ .../FeatureFlags/Queries/GetFlagTenants.cs | 8 +- .../FeatureFlags/Queries/GetFlagUsers.cs | 75 +++++++++ .../Tenants/Domain/TenantRepository.cs | 34 ++-- .../Features/Users/Domain/UserRepository.cs | 18 +++ .../Tests/FeatureFlags/FeatureFlagTests.cs | 150 ++++++++++++++++++ 10 files changed, 437 insertions(+), 23 deletions(-) create mode 100644 application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs create mode 100644 application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 88ad0256d..e870a930c 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -50,5 +50,17 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, long tenantId, IMediator mediator) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); + + group.MapGet("/{flagKey}/users", async Task> (string flagKey, IMediator mediator) + => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey }) + ).Produces(); + + group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ).DisableAntiforgery(); + + group.MapDelete("/{flagKey}/user-override", async Task (string flagKey, string userId, long tenantId, IMediator mediator) + => await mediator.Send(new RemoveUserFeatureFlagOverrideCommand { FlagKey = flagKey, UserId = userId, TenantId = tenantId }) + ).DisableAntiforgery(); } } diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 53274871d..accaa678a 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -41,6 +41,18 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); + internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, IMediator mediator) + => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey }) + ).Produces(); + + internalGroup.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) + => await mediator.Send(command with { FlagKey = flagKey }) + ).DisableAntiforgery(); + + internalGroup.MapDelete("/{flagKey}/user-override", async Task (string flagKey, string userId, long tenantId, IMediator mediator) + => await mediator.Send(new RemoveUserFeatureFlagOverrideCommand { FlagKey = flagKey, UserId = userId, TenantId = tenantId }) + ).DisableAntiforgery(); + // Authenticated API endpoints (tenant owner and user operations) var group = routes.MapGroup("/api/account/feature-flags").WithTags("FeatureFlags").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs new file mode 100644 index 000000000..2d942abcb --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs @@ -0,0 +1,55 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record RemoveUserFeatureFlagOverrideCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes from API contract + public string FlagKey { get; init; } = null!; + + public required string UserId { get; init; } + + public required long TenantId { get; init; } +} + +public sealed class RemoveUserFeatureFlagOverrideValidator : AbstractValidator +{ + public RemoveUserFeatureFlagOverrideValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + } +} + +public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(RemoveUserFeatureFlagOverrideCommand command, CancellationToken cancellationToken) + { + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + if (userOverride is null) return Result.NotFound($"No user override found for flag '{command.FlagKey}' and user '{command.UserId}'."); + + featureFlagRepository.Remove(userOverride); + + var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + if (tenant is not null) + { + tenant.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + } + + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs new file mode 100644 index 000000000..965109e23 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -0,0 +1,87 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record SetUserFeatureFlagInternalCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes from API contract + public string FlagKey { get; init; } = null!; + + public required string UserId { get; init; } + + public required long TenantId { get; init; } + + public required bool Enabled { get; init; } +} + +public sealed class SetUserFeatureFlagInternalValidator : AbstractValidator +{ + public SetUserFeatureFlagInternalValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + } +} + +public sealed class SetUserFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(SetUserFeatureFlagInternalCommand command, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + if (command.Enabled) + { + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + if (userOverride is null) + { + userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId, command.UserId); + userOverride.Activate(now); + await featureFlagRepository.AddAsync(userOverride, cancellationToken); + } + else + { + userOverride.Activate(now); + featureFlagRepository.Update(userOverride); + } + + events.CollectEvent(new FeatureFlagUserOverrideSet(command.FlagKey, command.UserId)); + } + else + { + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + if (userOverride is null) + { + userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId, command.UserId); + userOverride.Deactivate(now); + await featureFlagRepository.AddAsync(userOverride, cancellationToken); + } + else + { + userOverride.Deactivate(now); + featureFlagRepository.Update(userOverride); + } + + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId)); + } + + var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + if (tenant is not null) + { + tenant.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + } + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index 507c60472..e0d045595 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -14,6 +14,8 @@ public interface IFeatureFlagRepository : ICrudRepository GetTenantOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); Task GetByKeyAndScopeAsync(string flagKey, long? tenantId, string? userId, CancellationToken cancellationToken); + + Task GetUserOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); } internal sealed class FeatureFlagRepository(AccountDbContext accountDbContext) @@ -45,4 +47,11 @@ public async Task GetTenantOverridesForFlagAsync(string flagKey, return await DbSet .FirstOrDefaultAsync(f => f.FlagKey == flagKey && f.TenantId == tenantId && f.UserId == userId, cancellationToken); } + + public async Task GetUserOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken) + { + return await DbSet + .Where(f => f.FlagKey == flagKey && f.UserId != null) + .ToArrayAsync(cancellationToken); + } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs index 86fb8807b..7536741b6 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs @@ -19,7 +19,7 @@ public sealed record GetFlagTenantsQuery : IRequest { @@ -51,16 +51,16 @@ public async Task> Handle(GetFlagTenantsQuery que if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) { var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); - return new FlagTenantInfo(tenant.Id, tenant.Name, isEnabled, "manual_override"); + return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.RolloutBucket, isEnabled, "manual_override"); } if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { var isInRange = IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FlagTenantInfo(tenant.Id, tenant.Name, isInRange, "ab_rollout"); + return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.RolloutBucket, isInRange, "ab_rollout"); } - return new FlagTenantInfo(tenant.Id, tenant.Name, false, "default"); + return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.RolloutBucket, false, "default"); } ).ToArray(); diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs new file mode 100644 index 000000000..58e247eb3 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs @@ -0,0 +1,75 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetFlagUsersQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public string FlagKey { get; init; } = null!; +} + +[PublicAPI] +public sealed record GetFlagUsersResponse(FlagUserInfo[] Users); + +[PublicAPI] +public sealed record FlagUserInfo( + UserId UserId, + string Email, + string TenantName, + int RolloutBucket, + bool IsEnabled, + string Source +); + +public sealed class GetFlagUsersValidator : AbstractValidator +{ + public GetFlagUsersValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + } +} + +public sealed class GetFlagUsersHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetFlagUsersQuery query, CancellationToken cancellationToken) + { + var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); + if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); + + var userOverrides = await featureFlagRepository.GetUserOverridesForFlagAsync(query.FlagKey, cancellationToken); + if (userOverrides.Length == 0) return new GetFlagUsersResponse([]); + + var userIds = userOverrides.Select(f => new UserId(f.UserId!)).ToArray(); + var users = await userRepository.GetByIdsUnfilteredAsync(userIds, cancellationToken); + var usersById = users.ToDictionary(u => u.Id.Value); + + var tenantIds = users.Select(u => u.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var flagUsers = userOverrides.Select(overrideRow => + { + var userId = new UserId(overrideRow.UserId!); + var user = usersById.GetValueOrDefault(userId.Value); + var tenantName = user is not null && tenantsById.TryGetValue(user.TenantId, out var tenant) ? tenant.Name : "Unknown"; + var isEnabled = overrideRow.EnabledAt is not null && (overrideRow.DisabledAt is null || overrideRow.EnabledAt > overrideRow.DisabledAt); + var rolloutBucket = user?.RolloutBucket ?? 0; + return new FlagUserInfo(userId, user?.Email ?? "Unknown", tenantName, rolloutBucket, isEnabled, "manual_override"); + } + ).ToArray(); + + return new GetFlagUsersResponse(flagUsers); + } +} diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index bd1d8cfd2..90f65c857 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -16,6 +16,12 @@ public interface ITenantRepository : ICrudRepository, ISoftDel Task GetByIdsAsync(TenantId[] ids, CancellationToken cancellationToken); + /// + /// Retrieves tenants by IDs without applying tenant query filters. + /// This method should only be used for internal back-office operations that need cross-tenant access. + /// + Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); + /// /// Retrieves a tenant by ID without applying tenant query filters. /// This method should only be used in webhook processing where tenant context is not established. @@ -31,13 +37,6 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// Task> GetNamesByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); - /// - /// Loads tenants by id without applying tenant query filters. - /// Used by back-office cross-tenant queries that need full tenant data (logo, plan, ...) where - /// tenant context is not established. - /// - Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); - /// /// Returns every tenant created at or after without applying tenant query filters. /// Used by the back-office dashboard to compute new-tenant trend buckets across all tenants. @@ -76,6 +75,15 @@ public async Task GetByIdsAsync(TenantId[] ids, CancellationToken canc return await DbSet.Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); } + /// + /// Retrieves tenants by IDs without applying tenant query filters. + /// This method should only be used for internal back-office operations that need cross-tenant access. + /// + public async Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters().Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); + } + /// /// Retrieves a tenant by ID without applying tenant query filters. /// This method should only be used in webhook processing where tenant context is not established. @@ -120,18 +128,6 @@ public async Task> GetNamesByIdsUnfilteredAsync(Ten .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); } - /// - /// Loads tenants by id without applying tenant query filters. - /// Used by back-office cross-tenant queries that need full tenant data (logo, plan, ...) where - /// tenant context is not established. - /// - public async Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) - { - if (ids.Length == 0) return []; - - return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); - } - /// /// Returns every tenant created at or after without applying tenant query filters. /// Used by the back-office dashboard to compute new-tenant trend buckets across all tenants. diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 12458e37c..23d43c181 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -28,6 +28,12 @@ public interface IUserRepository : ICrudRepository, IBulkRemoveRep Task GetByIdsAsync(UserId[] ids, CancellationToken cancellationToken); + /// + /// Retrieves users by IDs without applying tenant query filters. + /// This method should only be used for internal back-office operations that need cross-tenant access. + /// + Task GetByIdsUnfilteredAsync(UserId[] ids, CancellationToken cancellationToken); + Task GetDeletedByIdsAsync(UserId[] ids, CancellationToken cancellationToken); Task<(User[] Users, int TotalItems, int TotalPages)> Search( @@ -165,6 +171,18 @@ public async Task GetByIdsAsync(UserId[] ids, CancellationToken cancella return await DbSet.Where(u => ids.AsEnumerable().Contains(u.Id)).ToArrayAsync(cancellationToken); } + /// + /// Retrieves users by IDs without applying tenant query filters. + /// This method should only be used for internal back-office operations that need cross-tenant access. + /// + public async Task GetByIdsUnfilteredAsync(UserId[] ids, CancellationToken cancellationToken) + { + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => ids.AsEnumerable().Contains(u.Id)) + .ToArrayAsync(cancellationToken); + } + public async Task GetDeletedByIdsAsync(UserId[] ids, CancellationToken cancellationToken) { return await DbSet diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index fe05567fc..d75467058 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -380,6 +380,156 @@ public async Task SetUserFeatureFlag_WhenNotUserScoped_ShouldFailValidation() TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } + // User override tests (internal API) + + [Fact] + public async Task SetUserFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var command = new SetUserFeatureFlagInternalCommand { UserId = userId, TenantId = tenantId, Enabled = true }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/user-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId }] + ); + rowCount.Should().Be(1); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagUserOverrideSet"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task RemoveUserFeatureFlagOverride_WhenOverrideExists_ShouldDeleteRow() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var overrideId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId), + ("user_id", userId), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false) + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId }] + ); + rowCount.Should().Be(0); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagUserOverrideRemoved"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldReturnNotFound() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetFlagUsers_WhenUserScopedFlag_ShouldReturnEmptyWhenNoOverrides() + { + // Arrange + var flagKey = "compact-view"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().BeEmpty(); + } + + [Fact] + public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOverrideSource() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var overrideId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId), + ("user_id", userId), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false) + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().HaveCount(1); + var userResult = result.Users[0]; + userResult.UserId.Value.Should().Be(userId); + userResult.IsEnabled.Should().BeTrue(); + userResult.Source.Should().Be("manual_override"); + userResult.Email.Should().NotBe("Unknown"); + userResult.TenantName.Should().NotBe("Unknown"); + } + + [Fact] + public async Task GetFlagUsers_WhenNonUserScopedFlag_ShouldReturnBadRequest() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/sso/users"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + // Rollout percentage tests [Fact] From 5c17b9c41218083d43ef46d31510d1fa3f7b85b5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 11:53:12 +0200 Subject: [PATCH 024/155] Add tenant ID to user flag info response --- .../routes/feature-flags/$flagKey.tsx | 183 +++++---------- .../-components/FlagInfoSection.tsx | 147 ++++++++++++ .../-components/TenantOverrideRow.tsx | 13 +- .../-components/TenantOverridesSection.tsx | 204 +++++++++++------ .../-components/UserOverrideRow.tsx | 144 ++++++++++++ .../-components/UserOverridesSection.tsx | 177 +++++++++++++++ .../feature-flags/-components/flagLabels.ts | 48 ++++ .../-components/rolloutBucket.ts | 48 ++++ .../routes/feature-flags/-components/types.ts | 12 + .../BackOffice/routes/feature-flags/index.tsx | 214 ++++++++---------- .../BackOffice/shared/lib/api/client.ts | 2 +- .../FeatureFlags/Queries/GetFlagUsers.cs | 4 +- .../tests/e2e/feature-flag-flows.spec.ts | 83 +++---- 13 files changed, 896 insertions(+), 383 deletions(-) create mode 100644 application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts create mode 100644 application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 8984a6e17..5b8b72f4d 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -1,23 +1,20 @@ import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; -import { Badge } from "@repo/ui/components/Badge"; -import { Button } from "@repo/ui/components/Button"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; -import { Switch } from "@repo/ui/components/Switch"; -import { TextField } from "@repo/ui/components/TextField"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeftIcon } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; -import { api } from "@/shared/lib/api/client"; +import { api, apiClient } from "@/shared/lib/api/client"; -import type { FeatureFlagInfo, GetFeatureFlagsResponse, GetFlagTenantsResponse } from "./-components/types"; +import type { GetFeatureFlagsResponse, GetFlagTenantsResponse, GetFlagUsersResponse } from "./-components/types"; +import { FlagInfoSection } from "./-components/FlagInfoSection"; +import { getFlagDescription, getFlagName } from "./-components/flagLabels"; import { TenantOverridesSection } from "./-components/TenantOverridesSection"; +import { UserOverridesSection } from "./-components/UserOverridesSection"; export const Route = createFileRoute("/feature-flags/$flagKey")({ staticData: { trackingTitle: "Feature flag detail" }, @@ -32,33 +29,58 @@ export default function FlagDetailPage() { isLoading: boolean; }; + const flag = flagsData?.flags?.find((f) => f.key === flagKey); + const { data: tenantsData, isLoading: isLoadingTenants } = api.useQuery( "get", "/api/back-office/feature-flags/{flagKey}/tenants", - { params: { path: { flagKey } } } + { params: { path: { flagKey } } }, + { enabled: flag?.scope === "Tenant" } ) as { data: GetFlagTenantsResponse | undefined; isLoading: boolean; }; - const flag = flagsData?.flags?.find((f) => f.key === flagKey); - const isLoading = isLoadingFlags || isLoadingTenants; + const { data: usersData, isLoading: isLoadingUsers } = useQuery({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users", { params: { path: { flagKey } } }], + queryFn: async () => { + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec + const { data } = await apiClient.GET("/api/back-office/feature-flags/{flagKey}/users" as any, { + params: { path: { flagKey } } + }); + return data as GetFlagUsersResponse | undefined; + }, + enabled: flag?.scope === "User" + }); + + const isLoading = + isLoadingFlags || (flag?.scope === "Tenant" && isLoadingTenants) || (flag?.scope === "User" && isLoadingUsers); + const flagName = flag ? getFlagName(flag.key) : flagKey; + const description = flag ? getFlagDescription(flag.key) || flag.description : ""; return ( - {flag?.description ?? flagKey} + {flagName}
} - subtitle={flag?.key} + subtitle={ + flag ? ( + + {description} +
+ {flag.key} +
+ ) : undefined + } > {isLoading ? ( @@ -68,8 +90,27 @@ export default function FlagDetailPage() { {flag.scope === "Tenant" && ( + )} + {flag.scope === "User" && ( + )}
@@ -80,118 +121,10 @@ export default function FlagDetailPage() { ); } -function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { - const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); - const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); - const isPending = activateMutation.isPending || deactivateMutation.isPending; - - const handleToggle = (checked: boolean) => { - const mutation = checked ? activateMutation : deactivateMutation; - mutation.mutate( - { params: { path: { flagKey: flag.key } } }, - { - onSuccess: () => { - toast.success(checked ? t`Feature flag activated` : t`Feature flag deactivated`); - } - } - ); - }; - - return ( -
-
-
- - Type - - {flag.scope} -
-
- {flag.isActive ? t`Active` : t`Inactive`} - -
-
- - {flag.isAbTestEligible && ( - - )} -
- ); -} - -function FlagTimestamps({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { - const timestamps = []; - if (flag.enabledAt) { - timestamps.push(`${t`Enabled`}: ${formatTimestamp(flag.enabledAt)}`); - } - if (flag.disabledAt) { - timestamps.push(`${t`Disabled`}: ${formatTimestamp(flag.disabledAt)}`); - } - - if (timestamps.length === 0) return null; - - return {timestamps.join(" | ")}; -} - -function formatTimestamp(isoDate: string): string { - const date = new Date(isoDate); - return new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "short", day: "numeric" }).format(date); -} - -function RolloutPercentageInput({ - flagKey, - currentPercentage -}: Readonly<{ - flagKey: string; - currentPercentage: number | null; -}>) { - const [percentage, setPercentage] = useState(String(currentPercentage ?? 0)); - - const rolloutMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/rollout-percentage"); - - const handleSave = () => { - const value = Number.parseInt(percentage, 10); - if (Number.isNaN(value) || value < 0 || value > 100) return; - - rolloutMutation.mutate( - { - params: { path: { flagKey } }, - body: { rolloutPercentage: value } - }, - { - onSuccess: () => { - toast.success(t`Rollout percentage updated`); - } - } - ); - }; - - return ( -
- setPercentage(value)} - className="w-32" - /> - -
- ); -} - function FlagDetailSkeleton() { return (
- +
diff --git a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx new file mode 100644 index 000000000..2a65585d1 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx @@ -0,0 +1,147 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Switch } from "@repo/ui/components/Switch"; +import { TextField } from "@repo/ui/components/TextField"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { InfoIcon } from "lucide-react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import { api } from "@/shared/lib/api/client"; + +import type { FeatureFlagInfo } from "./types"; + +import { getFlagName } from "./flagLabels"; +import { formatBucketRange } from "./rolloutBucket"; + +export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { + const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); + const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); + const isPending = activateMutation.isPending || deactivateMutation.isPending; + + const handleToggle = (checked: boolean) => { + const mutation = checked ? activateMutation : deactivateMutation; + mutation.mutate( + { params: { path: { flagKey: flag.key } } }, + { + onSuccess: () => { + toast.success(checked ? t`Feature flag activated` : t`Feature flag deactivated`); + } + } + ); + }; + + return ( +
+
+ +
+ {flag.isAbTestEligible && ( + + )} + {flag.isActive ? t`Active` : t`Inactive`} + +
+
+ {flag.bucketStart != null && flag.bucketEnd != null && flag.rolloutPercentage != null && ( +
+ {formatBucketRange(flag.bucketStart, flag.bucketEnd, flag.rolloutPercentage)} + + + + + + + Each account or user is assigned a fixed bucket (1-100) based on their ID. The rollout targets a + specific range of buckets, ensuring consistent and predictable feature rollout. + + + +
+ )} +
+ ); +} + +function getScopeLabel(scope: string): string { + switch (scope) { + case "Tenant": + return t`Account`; + case "User": + return t`User`; + case "System": + return t`System`; + default: + return scope; + } +} + +function FlagMetadata({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { + const items: string[] = []; + items.push(`${t`Type`}: ${getScopeLabel(flag.scope)}`); + if (flag.enabledAt) items.push(`${t`Enabled`}: ${formatTimestamp(flag.enabledAt)}`); + if (flag.disabledAt) items.push(`${t`Disabled`}: ${formatTimestamp(flag.disabledAt)}`); + + return {items.join(" \u00B7 ")}; +} + +function formatTimestamp(isoDate: string): string { + const date = new Date(isoDate); + return new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "long", day: "numeric" }).format(date); +} + +function RolloutPercentageInput({ + flagKey, + currentPercentage +}: Readonly<{ + flagKey: string; + currentPercentage: number | null; +}>) { + const [percentage, setPercentage] = useState(String(currentPercentage ?? 0)); + const lastSavedValue = useRef(String(currentPercentage ?? 0)); + + const rolloutMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/rollout-percentage"); + + const handleBlur = () => { + const value = Number.parseInt(percentage, 10); + if (Number.isNaN(value) || value < 0 || value > 100) { + setPercentage(lastSavedValue.current); + return; + } + if (percentage === lastSavedValue.current) return; + + rolloutMutation.mutate( + { + params: { path: { flagKey } }, + body: { rolloutPercentage: value } + }, + { + onSuccess: () => { + lastSavedValue.current = String(value); + toast.success(t`Rollout percentage updated`); + }, + onError: () => { + setPercentage(lastSavedValue.current); + } + } + ); + }; + + return ( + setPercentage(value)} + onBlur={handleBlur} + className="w-[6rem]" + /> + ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index e3ca3aa10..98ad3c3ef 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -1,6 +1,5 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Badge } from "@repo/ui/components/Badge"; import { Button } from "@repo/ui/components/Button"; import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; @@ -13,6 +12,8 @@ import { api, queryClient } from "@/shared/lib/api/client"; import type { FlagTenantInfo } from "./types"; +import { computeBucket } from "./rolloutBucket"; + function getSourceLabel(source: string): string { switch (source) { case "manual_override": @@ -29,11 +30,13 @@ function getSourceLabel(source: string): string { export function TenantOverrideRow({ flagKey, flagDescription, - tenant + tenant, + showBucket }: Readonly<{ flagKey: string; flagDescription: string; tenant: FlagTenantInfo; + showBucket: boolean; }>) { const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); @@ -91,14 +94,10 @@ export function TenantOverrideRow({ {tenant.tenantId} {tenant.tenantName} - - - {optimisticEnabled ? t`Enabled` : t`Disabled`} - - {getSourceLabel(tenant.source)} + {showBucket && {computeBucket(tenant.tenantId)}}
{tenant.source === "manual_override" && ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 31120933d..3f0fc624e 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -2,113 +2,175 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; +import { ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; +import type { BucketRange } from "./rolloutBucket"; import type { FlagTenantInfo } from "./types"; +import { sortBySourceThenBucket } from "./rolloutBucket"; import { TenantOverrideRow } from "./TenantOverrideRow"; -type SortColumn = "tenantName" | "isEnabled"; -type SortDirection = "ascending" | "descending"; - export function TenantOverridesSection({ flagKey, flagDescription, - tenants + tenants, + showBucket, + bucketRange }: Readonly<{ flagKey: string; flagDescription: string; tenants: FlagTenantInfo[]; + showBucket: boolean; + bucketRange: BucketRange | null; }>) { const [search, setSearch] = useState(""); - const [sortColumn, setSortColumn] = useState("tenantName"); - const [sortDirection, setSortDirection] = useState("ascending"); - const filteredAndSortedTenants = useMemo(() => { + const filtered = useMemo(() => { const lowerSearch = search.toLowerCase(); - const filtered = search + return search ? tenants.filter( (tenant) => tenant.tenantName.toLowerCase().includes(lowerSearch) || tenant.tenantId.includes(lowerSearch) ) : tenants; + }, [tenants, search]); - return [...filtered].sort((a, b) => { - const direction = sortDirection === "ascending" ? 1 : -1; - if (sortColumn === "tenantName") { - return a.tenantName.localeCompare(b.tenantName) * direction; - } - return (Number(b.isEnabled) - Number(a.isEnabled)) * direction; - }); - }, [tenants, search, sortColumn, sortDirection]); + const enabledTenants = useMemo( + () => + sortBySourceThenBucket( + filtered.filter((t) => t.isEnabled), + (t) => t.source, + (t) => t.tenantId, + "enabled", + bucketRange + ), + [filtered, bucketRange] + ); - const handleSortChange = (column: SortColumn) => { - if (sortColumn === column) { - setSortDirection((prev) => (prev === "ascending" ? "descending" : "ascending")); - } else { - setSortColumn(column); - setSortDirection("ascending"); - } - }; + const disabledTenants = useMemo( + () => + sortBySourceThenBucket( + filtered.filter((t) => !t.isEnabled), + (t) => t.source, + (t) => t.tenantId, + "disabled", + bucketRange + ), + [filtered, bucketRange] + ); + + const isSearching = search.length > 0; return (

- Tenant overrides + Account status

setSearch(value)} className="max-w-[20rem]" /> -
- - - - - Tenant ID - - handleSortChange("tenantName")} - aria-sort={sortColumn === "tenantName" ? sortDirection : undefined} - > - Tenant - {sortColumn === "tenantName" && } - - handleSortChange("isEnabled")} - aria-sort={sortColumn === "isEnabled" ? sortDirection : undefined} - > - Status - {sortColumn === "isEnabled" && } - - - Source - - - Override - - - - - {filteredAndSortedTenants.map((tenant) => ( - - ))} - -
-
+ {isSearching ? ( + + ) : ( + <> + + + + )}
); } -function SortIndicator({ direction }: Readonly<{ direction: SortDirection }>) { - return {direction === "ascending" ? "\u25B2" : "\u25BC"}; +interface TenantTableProps { + ariaLabel: string; + tenants: FlagTenantInfo[]; + flagKey: string; + flagDescription: string; + showBucket: boolean; +} + +function TenantTable({ ariaLabel, tenants, flagKey, flagDescription, showBucket }: Readonly) { + return ( + + + + + Account ID + + + Account + + + Source + + {showBucket && ( + + Bucket + + )} + + Override + + + + + {tenants.map((tenant) => ( + + ))} + +
+ ); +} + +function CollapsibleTenantGroup({ + label, + ...tableProps +}: Readonly<{ label: string } & Omit>) { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + {isOpen && } +
+ ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx new file mode 100644 index 000000000..281e34c41 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -0,0 +1,144 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Switch } from "@repo/ui/components/Switch"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { useMutation } from "@tanstack/react-query"; +import { XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { apiClient, queryClient } from "@/shared/lib/api/client"; + +import type { FlagUserInfo } from "./types"; + +import { computeBucket } from "./rolloutBucket"; + +function getSourceLabel(source: string): string { + switch (source) { + case "manual_override": + return t`Manual override`; + case "default": + return t`Default`; + default: + return source; + } +} + +export function UserOverrideRow({ + flagKey, + flagDescription, + user, + showBucket +}: Readonly<{ + flagKey: string; + flagDescription: string; + user: FlagUserInfo; + showBucket: boolean; +}>) { + const [optimisticEnabled, setOptimisticEnabled] = useState(user.isEnabled); + + const overrideMutation = useMutation({ + mutationFn: async (vars: { enabled: boolean }) => { + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec + const { error } = await apiClient.PUT("/api/back-office/feature-flags/{flagKey}/user-override" as any, { + params: { path: { flagKey } }, + body: { userId: user.userId, enabled: vars.enabled } + }); + if (error) throw error; + } + }); + + const removeMutation = useMutation({ + mutationFn: async () => { + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec + const { error } = await apiClient.DELETE("/api/back-office/feature-flags/{flagKey}/user-override" as any, { + params: { path: { flagKey }, query: { userId: user.userId } } + }); + if (error) throw error; + } + }); + + useEffect(() => { + if (!overrideMutation.isPending) { + setOptimisticEnabled(user.isEnabled); + } + }, [user.isEnabled, overrideMutation.isPending]); + + const handleToggle = (checked: boolean) => { + setOptimisticEnabled(checked); + overrideMutation.mutate( + { enabled: checked }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] + }); + const message = checked + ? t`${flagDescription} enabled for ${user.email}` + : t`${flagDescription} disabled for ${user.email}`; + toast.success(message); + }, + onError: () => { + setOptimisticEnabled(user.isEnabled); + } + } + ); + }; + + const handleRemoveOverride = () => { + removeMutation.mutate(undefined, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] + }); + toast.success(t`Override removed for ${user.email}`); + } + }); + }; + + const isPending = overrideMutation.isPending || removeMutation.isPending; + + return ( + + {user.email} + {user.tenantName} + + {getSourceLabel(user.source)} + + {showBucket && {computeBucket(user.userId)}} + +
+ {user.source === "manual_override" && ( + + + } + > + + + + Remove override + + + )} + +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx new file mode 100644 index 000000000..6a27e6d77 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -0,0 +1,177 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TextField } from "@repo/ui/components/TextField"; +import { ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { BucketRange } from "./rolloutBucket"; +import type { FlagUserInfo } from "./types"; + +import { sortBySourceThenBucket } from "./rolloutBucket"; +import { UserOverrideRow } from "./UserOverrideRow"; + +export function UserOverridesSection({ + flagKey, + flagDescription, + users, + showBucket, + bucketRange +}: Readonly<{ + flagKey: string; + flagDescription: string; + users: FlagUserInfo[]; + showBucket: boolean; + bucketRange: BucketRange | null; +}>) { + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + const lowerSearch = search.toLowerCase(); + return search + ? users.filter( + (user) => + user.email.toLowerCase().includes(lowerSearch) || user.tenantName.toLowerCase().includes(lowerSearch) + ) + : users; + }, [users, search]); + + const enabledUsers = useMemo( + () => + sortBySourceThenBucket( + filtered.filter((u) => u.isEnabled), + (u) => u.source, + (u) => u.userId, + "enabled", + bucketRange + ), + [filtered, bucketRange] + ); + + const disabledUsers = useMemo( + () => + sortBySourceThenBucket( + filtered.filter((u) => !u.isEnabled), + (u) => u.source, + (u) => u.userId, + "disabled", + bucketRange + ), + [filtered, bucketRange] + ); + + const isSearching = search.length > 0; + + return ( +
+

+ User status +

+ setSearch(value)} + className="max-w-[20rem]" + /> + {isSearching ? ( + + ) : ( + <> + + + + )} +
+ ); +} + +interface UserTableProps { + ariaLabel: string; + users: FlagUserInfo[]; + flagKey: string; + flagDescription: string; + showBucket: boolean; +} + +function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket }: Readonly) { + return ( + + + + + Email + + + Account + + + Source + + {showBucket && ( + + Bucket + + )} + + Override + + + + + {users.map((user) => ( + + ))} + +
+ ); +} + +function CollapsibleUserGroup({ + label, + ...tableProps +}: Readonly<{ label: string } & Omit>) { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + {isOpen && } +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts new file mode 100644 index 000000000..a50b8f84d --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts @@ -0,0 +1,48 @@ +import { t } from "@lingui/core/macro"; + +interface FlagLabel { + name: string; + description: string; +} + +function getKnownFlagLabels(): Record { + return { + "google-oauth": { + name: t`Google OAuth`, + description: t`Sign in with Google using OpenID Connect` + }, + subscriptions: { + name: t`Subscriptions`, + description: t`Stripe-powered subscription billing and plan management` + }, + "beta-features": { + name: t`Beta features`, + description: t`Early access to experimental features before general availability` + }, + sso: { + name: t`Single sign-on`, + description: t`Allow users to authenticate using enterprise identity providers` + }, + "custom-branding": { + name: t`Custom branding`, + description: t`Customize the login page with your organization's logo and colors` + }, + "compact-view": { + name: t`Compact view`, + description: t`Reduce spacing between UI elements for a denser layout` + } + }; +} + +function formatFlagKey(flagKey: string): string { + const formatted = flagKey.replace(/-/g, " "); + return formatted.charAt(0).toUpperCase() + formatted.slice(1); +} + +export function getFlagName(flagKey: string): string { + return getKnownFlagLabels()[flagKey]?.name ?? formatFlagKey(flagKey); +} + +export function getFlagDescription(flagKey: string): string { + return getKnownFlagLabels()[flagKey]?.description ?? ""; +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts new file mode 100644 index 000000000..dbce228bf --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts @@ -0,0 +1,48 @@ +import { t } from "@lingui/core/macro"; + +const FNV_OFFSET_BASIS = 2166136261; +const FNV_PRIME = 16777619; +const BUCKET_MAX = 100; + +export function computeBucket(entityId: string): number { + let hash = FNV_OFFSET_BASIS; + for (let i = 0; i < entityId.length; i++) { + hash ^= entityId.charCodeAt(i); + hash = Math.imul(hash, FNV_PRIME) >>> 0; + } + return (hash % 99) + 1; +} + +export function formatBucketRange(bucketStart: number, bucketEnd: number, rolloutPercentage: number): string { + if (bucketStart <= bucketEnd) { + return t`Rollout buckets: ${bucketStart}-${bucketEnd} (${rolloutPercentage}%)`; + } + return t`Rollout buckets: ${bucketStart}-100 and 1-${bucketEnd} (${rolloutPercentage}%)`; +} + +export interface BucketRange { + bucketStart: number; + bucketEnd: number; +} + +function bucketSortOrder(entityId: string, group: "enabled" | "disabled", range: BucketRange): number { + const bucket = computeBucket(entityId); + const ref = group === "disabled" ? range.bucketEnd : range.bucketStart; + return (bucket - ref + BUCKET_MAX) % BUCKET_MAX; +} + +export function sortBySourceThenBucket( + items: T[], + getSource: (item: T) => string, + getEntityId: (item: T) => string, + group: "enabled" | "disabled", + bucketRange: BucketRange | null +): T[] { + return [...items].sort((a, b) => { + const aOrder = + getSource(a) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getEntityId(a), group, bucketRange) : 0; + const bOrder = + getSource(b) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getEntityId(b), group, bucketRange) : 0; + return aOrder - bOrder; + }); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 2791abf25..bf83f1b85 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -30,3 +30,15 @@ export interface FlagTenantInfo { export interface GetFlagTenantsResponse { tenants: FlagTenantInfo[]; } + +export interface FlagUserInfo { + userId: string; + email: string; + tenantName: string; + isEnabled: boolean; + source: "manual_override" | "default"; +} + +export interface GetFlagUsersResponse { + users: FlagUserInfo[]; +} diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index ebd29aec4..b51f008c8 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -4,170 +4,134 @@ import { AppLayout } from "@repo/ui/components/AppLayout"; import { Badge } from "@repo/ui/components/Badge"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; -import { Switch } from "@repo/ui/components/Switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; +import { useMemo } from "react"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; import { api } from "@/shared/lib/api/client"; -import type { FeatureFlagInfo, GetFeatureFlagsResponse } from "./-components/types"; +import type { FeatureFlagInfo, FeatureFlagScope, GetFeatureFlagsResponse } from "./-components/types"; -type TabFilter = "all" | "Tenant" | "User" | "System"; +import { getFlagDescription, getFlagName } from "./-components/flagLabels"; export const Route = createFileRoute("/feature-flags/")({ staticData: { trackingTitle: "Feature flags" }, component: FeatureFlagsPage }); +interface FlagGroup { + scope: FeatureFlagScope; + label: string; + flags: FeatureFlagInfo[]; +} + export default function FeatureFlagsPage() { - const [activeTab, setActiveTab] = useState("all"); const { data, isLoading } = api.useQuery("get", "/api/back-office/feature-flags") as { data: GetFeatureFlagsResponse | undefined; isLoading: boolean; }; - const filteredFlags = useMemo(() => { + const groups = useMemo(() => { if (!data?.flags) return []; - if (activeTab === "all") return data.flags; - return data.flags.filter((flag) => flag.scope === activeTab); - }, [data?.flags, activeTab]); + const scopeOrder: FeatureFlagScope[] = ["Tenant", "User", "System"]; + const scopeLabels: Record = { + Tenant: t`Account flags`, + User: t`User flags`, + System: t`System flags` + }; + return scopeOrder + .map((scope) => ({ + scope, + label: scopeLabels[scope], + flags: data.flags.filter((flag) => flag.scope === scope) + })) + .filter((group) => group.flags.length > 0); + }, [data?.flags]); return ( - setActiveTab(value as TabFilter)} - className="relative z-10 mb-4" - > - - - All - - - Tenant - - - User - - - System - - - - - {isLoading ? : } - - + {isLoading ? : } ); } -function FeatureFlagTable({ flags }: Readonly<{ flags: FeatureFlagInfo[] }>) { +function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { const navigate = useNavigate(); + const hasDetail = (scope: FeatureFlagScope) => scope === "Tenant" || scope === "User"; return ( -
- - - - - Flag name - - - Type - - - Rollout - - - Status - - - - - {flags.map((flag) => ( - { - if (flag.scope === "Tenant") { - navigate({ to: "/feature-flags/$flagKey", params: { flagKey: flag.key } }); - } - }} - /> - ))} - -
+
+ {groups.map((group) => { + const showRollout = group.scope !== "System"; + return ( +
+

{group.label}

+ + + + + Name + + {showRollout && ( + + Rollout + + )} + + Status + + + + + {group.flags.map((flag) => ( + navigate({ to: "/feature-flags/$flagKey", params: { flagKey: flag.key } }) + : undefined + } + > + +
+ {getFlagName(flag.key)} + + {getFlagDescription(flag.key) || flag.description} + +
+
+ {showRollout && ( + + {flag.rolloutPercentage !== null ? ( + `${flag.rolloutPercentage}%` + ) : ( + -- + )} + + )} + + + {flag.isActive ? t`Active` : t`Inactive`} + + +
+ ))} +
+
+
+ ); + })}
); } -function FeatureFlagRow({ - flag, - onClick -}: Readonly<{ - flag: FeatureFlagInfo; - onClick: () => void; -}>) { - const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); - const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); - const isPending = activateMutation.isPending || deactivateMutation.isPending; - - const handleToggle = (checked: boolean) => { - if (flag.scope === "System") return; - - const mutation = checked ? activateMutation : deactivateMutation; - mutation.mutate( - { params: { path: { flagKey: flag.key } } }, - { - onSuccess: () => { - toast.success(checked ? t`Feature flag activated` : t`Feature flag deactivated`); - } - } - ); - }; - - const isTenantScoped = flag.scope === "Tenant"; - - return ( - - {flag.description} - - {flag.scope} - - - {flag.rolloutPercentage !== null ? ( - `${flag.rolloutPercentage}%` - ) : ( - -- - )} - - - {flag.scope === "System" ? ( - {flag.isActive ? t`Active` : t`Inactive`} - ) : ( - event.stopPropagation()} - /> - )} - - - ); -} - function FeatureFlagsSkeleton() { return (
diff --git a/application/account/BackOffice/shared/lib/api/client.ts b/application/account/BackOffice/shared/lib/api/client.ts index 9dea4fbbd..ccd08f64d 100644 --- a/application/account/BackOffice/shared/lib/api/client.ts +++ b/application/account/BackOffice/shared/lib/api/client.ts @@ -4,4 +4,4 @@ import type { components, paths } from "./api.generated"; export * from "./api.generated.d"; export type Schemas = components["schemas"]; -export const { api, queryClient } = createApiClient(); +export const { api, apiClient, queryClient } = createApiClient(); diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs index 58e247eb3..ad9149198 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs @@ -22,6 +22,7 @@ public sealed record GetFlagUsersResponse(FlagUserInfo[] Users); [PublicAPI] public sealed record FlagUserInfo( UserId UserId, + TenantId TenantId, string Email, string TenantName, int RolloutBucket, @@ -65,8 +66,9 @@ public async Task> Handle(GetFlagUsersQuery query, var user = usersById.GetValueOrDefault(userId.Value); var tenantName = user is not null && tenantsById.TryGetValue(user.TenantId, out var tenant) ? tenant.Name : "Unknown"; var isEnabled = overrideRow.EnabledAt is not null && (overrideRow.DisabledAt is null || overrideRow.EnabledAt > overrideRow.DisabledAt); + var tenantId = user?.TenantId ?? new TenantId(0); var rolloutBucket = user?.RolloutBucket ?? 0; - return new FlagUserInfo(userId, user?.Email ?? "Unknown", tenantName, rolloutBucket, isEnabled, "manual_override"); + return new FlagUserInfo(userId, tenantId, user?.Email ?? "Unknown", tenantName, rolloutBucket, isEnabled, "manual_override"); } ).ToArray(); diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index bbbdca211..dc8de9783 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; import { getBackOfficeBaseUrl } from "@shared/e2e/utils/constants"; -import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { blurActiveElement, createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; import { step } from "@shared/e2e/utils/test-step-wrapper"; const BACK_OFFICE_BASE_URL = getBackOfficeBaseUrl(); @@ -11,7 +11,7 @@ test.describe("@smoke", () => { * FEATURE FLAG SYSTEM E2E TEST * * Tests the full feature flag management flow: - * - Back-office flag list: view flags, filter by scope tabs, toggle a flag via switch + * - Back-office flag list: view flags grouped by scope (Tenant, User, System) * - Back-office flag detail: navigate into tenant-scoped flag, toggle tenant override twice, set A/B rollout percentage * - Account settings: verify Features section, toggle tenant-scoped custom branding flag * - User preferences: verify Beta features section, toggle user-scoped compact view flag @@ -37,86 +37,63 @@ test.describe("@smoke", () => { // === BACK-OFFICE FLAG LIST === - await step("Load feature flags page & verify flag list renders with expected flags")(async () => { + await step("Load feature flags page & verify flags grouped by scope")(async () => { await expect(page.getByRole("heading", { name: "Feature flags" })).toBeVisible(); - const table = page.getByRole("table", { name: "Feature flags" }); - await expect(table.getByText("Google OAuth authentication")).toBeVisible(); - await expect(table.getByText("Subscription billing via Stripe")).toBeVisible(); - await expect(table.getByText("Enables beta features for tenants")).toBeVisible(); - await expect(table.getByText("Enables single sign-on for tenants")).toBeVisible(); - await expect(table.getByText("Enables custom branding options for tenants")).toBeVisible(); - await expect(table.getByText("Enables compact view in the user interface")).toBeVisible(); - })(); - - await step("Filter by Tenant tab & verify only tenant-scoped flags appear")(async () => { - await page.getByRole("tab", { name: "Tenant" }).click(); - - const table = page.getByRole("table", { name: "Feature flags" }); - await expect(table.getByText("Enables beta features for tenants")).toBeVisible(); - await expect(table.getByText("Enables single sign-on for tenants")).toBeVisible(); - await expect(table.getByText("Enables custom branding options for tenants")).toBeVisible(); - await expect(table.getByText("Google OAuth authentication")).not.toBeVisible(); - await expect(table.getByText("Enables compact view in the user interface")).not.toBeVisible(); - })(); - - await step("Switch back to All tab & verify all flags visible again")(async () => { - await page.getByRole("tab", { name: "All" }).click(); - - const table = page.getByRole("table", { name: "Feature flags" }); - await expect(table.getByText("Google OAuth authentication")).toBeVisible(); - await expect(table.getByText("Enables compact view in the user interface")).toBeVisible(); - })(); + const tenantTable = page.getByRole("table", { name: "Account flags" }); + await expect(tenantTable.getByText("Beta features")).toBeVisible(); + await expect(tenantTable.getByText("Single sign-on")).toBeVisible(); + await expect(tenantTable.getByText("Custom branding")).toBeVisible(); - await step("Toggle compact view flag & verify success toast")(async () => { - const toggle = page.getByRole("switch", { - name: "Toggle Enables compact view in the user interface" - }); - await toggle.click(); + const userTable = page.getByRole("table", { name: "User flags" }); + await expect(userTable.getByText("Compact view")).toBeVisible(); - await expectToastMessage(context, "Feature flag"); + const systemTable = page.getByRole("table", { name: "System flags" }); + await expect(systemTable.getByText("Google OAuth")).toBeVisible(); + await expect(systemTable.getByText("Subscriptions")).toBeVisible(); })(); // === BACK-OFFICE FLAG DETAIL === await step("Click into beta-features flag detail & verify detail page loads")(async () => { - const table = page.getByRole("table", { name: "Feature flags" }); - const betaRow = table.locator("tr").filter({ hasText: "Enables beta features for tenants" }); + const tenantTable = page.getByRole("table", { name: "Account flags" }); + const betaRow = tenantTable.locator("tr").filter({ hasText: "Beta features" }); await betaRow.click(); await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); - await expect(page.getByRole("heading", { name: "Tenant overrides" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Account status" })).toBeVisible(); })(); - await step("Toggle tenant override & verify toast confirms state change")(async () => { - const overridesTable = page.getByRole("table", { name: "Tenant overrides" }); - await expect(overridesTable).toBeVisible(); + await step("Search for a tenant & verify search results table appears")(async () => { + await page.getByPlaceholder("Search by account name or ID").fill("test"); - const firstRow = overridesTable.locator("tbody tr").first(); - const overrideSwitch = firstRow.getByRole("switch"); + await expect(page.getByRole("table", { name: "Search results" })).toBeVisible(); + })(); + + await step("Toggle tenant override & verify toast confirms state change")(async () => { + const overrideSwitch = page.getByRole("table", { name: "Search results" }).getByRole("switch").first(); await overrideSwitch.click(); - await expectToastMessage(context, "beta features for tenants"); + await expectToastMessage(context, "Beta features"); })(); await step("Toggle tenant override back & verify toast confirms state change")(async () => { - const overridesTable = page.getByRole("table", { name: "Tenant overrides" }); - const firstRow = overridesTable.locator("tbody tr").first(); - const overrideSwitch = firstRow.getByRole("switch"); + const overrideSwitch = page.getByRole("table", { name: "Search results" }).getByRole("switch").first(); await overrideSwitch.click(); - await expectToastMessage(context, "beta features for tenants"); + await expectToastMessage(context, "Beta features"); })(); - await step("Set A/B rollout percentage & verify success toast")(async () => { + await step("Set A/B rollout percentage & verify success toast on blur")(async () => { const percentageInput = page.getByRole("spinbutton", { name: "Rollout percentage" }); - await percentageInput.fill("50"); - await page.getByRole("button", { name: "Save" }).click(); + const newValue = String(10 + (Date.now() % 80)); + await percentageInput.fill(newValue); + await blurActiveElement(page); await expectToastMessage(context, "Rollout percentage updated"); })(); - await step("Click back link & verify return to flag list page")(async () => { + await step("Navigate back to flag list & verify return to list page")(async () => { await page.getByRole("link", { name: "Back to feature flags" }).click(); await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags`); From a95b467066750d6ed20a256021004d3e6408d0a5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 12:05:01 +0200 Subject: [PATCH 025/155] Polish feature flag UI with dimmed switches, bucket sorting, collapsible groups, Account rename, and user-scoped detail --- .../routes/feature-flags/$flagKey.tsx | 2 ++ .../-components/TenantOverrideRow.tsx | 9 +++++---- .../-components/TenantOverridesSection.tsx | 18 ++++++++++++++++-- .../-components/UserOverrideRow.tsx | 13 +++++++------ .../-components/UserOverridesSection.tsx | 11 +++++++++-- .../routes/feature-flags/-components/types.ts | 4 ++++ .../BackOffice/routes/feature-flags/index.tsx | 4 ++-- 7 files changed, 45 insertions(+), 16 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 5b8b72f4d..574480903 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -98,6 +98,7 @@ export default function FlagDetailPage() { ? { bucketStart: flag.bucketStart, bucketEnd: flag.bucketEnd } : null } + isFlagActive={flag.isActive} /> )} {flag.scope === "User" && ( @@ -111,6 +112,7 @@ export default function FlagDetailPage() { ? { bucketStart: flag.bucketStart, bucketEnd: flag.bucketEnd } : null } + isFlagActive={flag.isActive} /> )}
diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index 98ad3c3ef..1809a70f8 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -12,8 +12,6 @@ import { api, queryClient } from "@/shared/lib/api/client"; import type { FlagTenantInfo } from "./types"; -import { computeBucket } from "./rolloutBucket"; - function getSourceLabel(source: string): string { switch (source) { case "manual_override": @@ -31,12 +29,14 @@ export function TenantOverrideRow({ flagKey, flagDescription, tenant, - showBucket + showBucket, + isFlagActive }: Readonly<{ flagKey: string; flagDescription: string; tenant: FlagTenantInfo; showBucket: boolean; + isFlagActive: boolean; }>) { const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); @@ -97,7 +97,7 @@ export function TenantOverrideRow({ {getSourceLabel(tenant.source)} - {showBucket && {computeBucket(tenant.tenantId)}} + {showBucket && {tenant.rolloutBucket}}
{tenant.source === "manual_override" && ( @@ -125,6 +125,7 @@ export function TenantOverrideRow({ checked={optimisticEnabled} onCheckedChange={handleToggle} disabled={isPending} + className={!isFlagActive && optimisticEnabled ? "opacity-50" : ""} aria-label={t`Override for ${tenant.tenantName}`} />
diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 3f0fc624e..77a6bb877 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -16,13 +16,15 @@ export function TenantOverridesSection({ flagDescription, tenants, showBucket, - bucketRange + bucketRange, + isFlagActive }: Readonly<{ flagKey: string; flagDescription: string; tenants: FlagTenantInfo[]; showBucket: boolean; bucketRange: BucketRange | null; + isFlagActive: boolean; }>) { const [search, setSearch] = useState(""); @@ -80,6 +82,7 @@ export function TenantOverridesSection({ flagKey={flagKey} flagDescription={flagDescription} showBucket={showBucket} + isFlagActive={isFlagActive} /> ) : ( <> @@ -89,6 +92,7 @@ export function TenantOverridesSection({ flagKey={flagKey} flagDescription={flagDescription} showBucket={showBucket} + isFlagActive={isFlagActive} /> )} @@ -109,9 +114,17 @@ interface TenantTableProps { flagKey: string; flagDescription: string; showBucket: boolean; + isFlagActive: boolean; } -function TenantTable({ ariaLabel, tenants, flagKey, flagDescription, showBucket }: Readonly) { +function TenantTable({ + ariaLabel, + tenants, + flagKey, + flagDescription, + showBucket, + isFlagActive +}: Readonly) { return ( @@ -143,6 +156,7 @@ function TenantTable({ ariaLabel, tenants, flagKey, flagDescription, showBucket flagDescription={flagDescription} tenant={tenant} showBucket={showBucket} + isFlagActive={isFlagActive} /> ))} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index 281e34c41..8df829c67 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -13,8 +13,6 @@ import { apiClient, queryClient } from "@/shared/lib/api/client"; import type { FlagUserInfo } from "./types"; -import { computeBucket } from "./rolloutBucket"; - function getSourceLabel(source: string): string { switch (source) { case "manual_override": @@ -30,12 +28,14 @@ export function UserOverrideRow({ flagKey, flagDescription, user, - showBucket + showBucket, + isFlagActive }: Readonly<{ flagKey: string; flagDescription: string; user: FlagUserInfo; showBucket: boolean; + isFlagActive: boolean; }>) { const [optimisticEnabled, setOptimisticEnabled] = useState(user.isEnabled); @@ -44,7 +44,7 @@ export function UserOverrideRow({ // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec const { error } = await apiClient.PUT("/api/back-office/feature-flags/{flagKey}/user-override" as any, { params: { path: { flagKey } }, - body: { userId: user.userId, enabled: vars.enabled } + body: { userId: user.userId, tenantId: user.tenantId, enabled: vars.enabled } }); if (error) throw error; } @@ -54,7 +54,7 @@ export function UserOverrideRow({ mutationFn: async () => { // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec const { error } = await apiClient.DELETE("/api/back-office/feature-flags/{flagKey}/user-override" as any, { - params: { path: { flagKey }, query: { userId: user.userId } } + params: { path: { flagKey }, query: { userId: user.userId, tenantId: user.tenantId } } }); if (error) throw error; } @@ -107,7 +107,7 @@ export function UserOverrideRow({ {getSourceLabel(user.source)} - {showBucket && {computeBucket(user.userId)}} + {showBucket && {user.rolloutBucket}}
{user.source === "manual_override" && ( @@ -135,6 +135,7 @@ export function UserOverrideRow({ checked={optimisticEnabled} onCheckedChange={handleToggle} disabled={isPending} + className={!isFlagActive && optimisticEnabled ? "opacity-50" : ""} aria-label={t`Override for ${user.email}`} />
diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 6a27e6d77..6cdb51385 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -16,13 +16,15 @@ export function UserOverridesSection({ flagDescription, users, showBucket, - bucketRange + bucketRange, + isFlagActive }: Readonly<{ flagKey: string; flagDescription: string; users: FlagUserInfo[]; showBucket: boolean; bucketRange: BucketRange | null; + isFlagActive: boolean; }>) { const [search, setSearch] = useState(""); @@ -81,6 +83,7 @@ export function UserOverridesSection({ flagKey={flagKey} flagDescription={flagDescription} showBucket={showBucket} + isFlagActive={isFlagActive} /> ) : ( <> @@ -90,6 +93,7 @@ export function UserOverridesSection({ flagKey={flagKey} flagDescription={flagDescription} showBucket={showBucket} + isFlagActive={isFlagActive} /> )} @@ -110,9 +115,10 @@ interface UserTableProps { flagKey: string; flagDescription: string; showBucket: boolean; + isFlagActive: boolean; } -function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket }: Readonly) { +function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket, isFlagActive }: Readonly) { return (
@@ -144,6 +150,7 @@ function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket }: R flagDescription={flagDescription} user={user} showBucket={showBucket} + isFlagActive={isFlagActive} /> ))} diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index bf83f1b85..853a8cfc7 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -14,6 +14,7 @@ export interface FeatureFlagInfo { bucketEnd: number | null; rolloutPercentage: number | null; isActive: boolean; + createdAt: string | null; } export interface GetFeatureFlagsResponse { @@ -25,6 +26,7 @@ export interface FlagTenantInfo { tenantName: string; isEnabled: boolean; source: "manual_override" | "ab_rollout" | "default"; + rolloutBucket: number; } export interface GetFlagTenantsResponse { @@ -33,10 +35,12 @@ export interface GetFlagTenantsResponse { export interface FlagUserInfo { userId: string; + tenantId: string; email: string; tenantName: string; isEnabled: boolean; source: "manual_override" | "default"; + rolloutBucket: number; } export interface GetFlagUsersResponse { diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index b51f008c8..c0801db18 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -100,9 +100,9 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { } > -
+
{getFlagName(flag.key)} - + {getFlagDescription(flag.key) || flag.description}
From d905f6c4dfb3f3e865d25a774bd4ee219eb9f353 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 12:35:13 +0200 Subject: [PATCH 026/155] Add user-scoped A/B example flag and subscription plan to tenant info --- .../FeatureFlags/Queries/GetFlagTenants.cs | 15 +++++++++++---- application/account/Tests/DatabaseSeeder.cs | 5 +++++ .../SharedKernel/FeatureFlags/FeatureFlags.cs | 11 ++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs index 7536741b6..e29711fef 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs @@ -19,7 +19,14 @@ public sealed record GetFlagTenantsQuery : IRequest { @@ -51,16 +58,16 @@ public async Task> Handle(GetFlagTenantsQuery que if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) { var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); - return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.RolloutBucket, isEnabled, "manual_override"); + return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isEnabled, "manual_override"); } if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { var isInRange = IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.RolloutBucket, isInRange, "ab_rollout"); + return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isInRange, "ab_rollout"); } - return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.RolloutBucket, false, "default"); + return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, false, "default"); } ).ToArray(); diff --git a/application/account/Tests/DatabaseSeeder.cs b/application/account/Tests/DatabaseSeeder.cs index f14750117..4e16efe45 100644 --- a/application/account/Tests/DatabaseSeeder.cs +++ b/application/account/Tests/DatabaseSeeder.cs @@ -13,6 +13,7 @@ public sealed class DatabaseSeeder public readonly FeatureFlag BetaFeaturesFlag; public readonly FeatureFlag CompactViewFlag; public readonly FeatureFlag CustomBrandingFlag; + public readonly FeatureFlag ExperimentalUiFlag; public readonly FeatureFlag SsoFlag; public readonly Tenant Tenant1; public readonly User Tenant1Member; @@ -58,6 +59,10 @@ public DatabaseSeeder(AccountDbContext accountDbContext) CompactViewFlag.Activate(now); accountDbContext.Set().Add(CompactViewFlag); + ExperimentalUiFlag = FeatureFlag.Create("experimental-ui"); + ExperimentalUiFlag.Activate(now); + accountDbContext.Set().Add(ExperimentalUiFlag); + accountDbContext.SaveChanges(); } } diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs index 00cdfc946..b2b4e5485 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -49,7 +49,16 @@ public static class FeatureFlags ConfigurableByUser: true ); - private static readonly FeatureFlagDefinition[] AllFlags = [GoogleOauth, Subscriptions, BetaFeatures, Sso, CustomBranding, CompactView]; + public static readonly FeatureFlagDefinition ExperimentalUi = new( + "experimental-ui", + FeatureFlagScope.User, + FeatureFlagAdminLevel.User, + "Enables experimental UI components for users", + IsAbTestEligible: true, + TrackInTelemetry: true + ); + + private static readonly FeatureFlagDefinition[] AllFlags = [GoogleOauth, Subscriptions, BetaFeatures, Sso, CustomBranding, CompactView, ExperimentalUi]; static FeatureFlags() { From fcf25fd6c53ff6cfb3202841d3911a8399856b88 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 12:35:37 +0200 Subject: [PATCH 027/155] Polish feature flag UI with scope icons, user empty state, plan column, and responsive layout --- .../-components/FlagInfoSection.tsx | 38 +++++++------- .../feature-flags/-components/ScopeIcon.tsx | 25 ++++++++++ .../-components/TenantOverrideRow.tsx | 3 +- .../-components/TenantOverridesSection.tsx | 5 +- .../-components/UserEmptyState.tsx | 22 ++++++++ .../-components/UserOverridesSection.tsx | 50 ++++++++++--------- .../feature-flags/-components/flagLabels.ts | 4 ++ .../routes/feature-flags/-components/types.ts | 1 + .../BackOffice/routes/feature-flags/index.tsx | 6 ++- .../shared/translations/locale/da-DK.po | 15 ++++++ .../shared/lib/api/featureFlagLabels.ts | 4 ++ .../shared/translations/locale/da-DK.po | 6 +++ .../shared/translations/locale/en-US.po | 6 +++ 13 files changed, 139 insertions(+), 46 deletions(-) create mode 100644 application/account/BackOffice/routes/feature-flags/-components/ScopeIcon.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx diff --git a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx index 2a65585d1..2d137598b 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx @@ -14,6 +14,7 @@ import type { FeatureFlagInfo } from "./types"; import { getFlagName } from "./flagLabels"; import { formatBucketRange } from "./rolloutBucket"; +import { ScopeIcon } from "./ScopeIcon"; export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); @@ -69,26 +70,23 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { ); } -function getScopeLabel(scope: string): string { - switch (scope) { - case "Tenant": - return t`Account`; - case "User": - return t`User`; - case "System": - return t`System`; - default: - return scope; - } -} - function FlagMetadata({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { - const items: string[] = []; - items.push(`${t`Type`}: ${getScopeLabel(flag.scope)}`); - if (flag.enabledAt) items.push(`${t`Enabled`}: ${formatTimestamp(flag.enabledAt)}`); - if (flag.disabledAt) items.push(`${t`Disabled`}: ${formatTimestamp(flag.disabledAt)}`); - - return {items.join(" \u00B7 ")}; + return ( + + + {flag.enabledAt && ( + + {t`Enabled`}: {formatTimestamp(flag.enabledAt)} + + )} + {flag.enabledAt && flag.disabledAt && {"\u00B7"}} + {flag.disabledAt && ( + + {t`Disabled`}: {formatTimestamp(flag.disabledAt)} + + )} + + ); } function formatTimestamp(isoDate: string): string { @@ -141,7 +139,7 @@ function RolloutPercentageInput({ value={percentage} onChange={(value) => setPercentage(value)} onBlur={handleBlur} - className="w-[6rem]" + className="w-[6rem] whitespace-nowrap" /> ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/ScopeIcon.tsx b/application/account/BackOffice/routes/feature-flags/-components/ScopeIcon.tsx new file mode 100644 index 000000000..9919edef0 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/ScopeIcon.tsx @@ -0,0 +1,25 @@ +import { t } from "@lingui/core/macro"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { GlobeIcon, UserIcon, UsersIcon } from "lucide-react"; + +import type { FeatureFlagScope } from "./types"; + +const scopeConfig: Record string }> = { + Tenant: { icon: UsersIcon, label: () => t`Account` }, + User: { icon: UserIcon, label: () => t`User` }, + System: { icon: GlobeIcon, label: () => t`System` } +}; + +export function ScopeIcon({ scope, className }: Readonly<{ scope: FeatureFlagScope; className?: string }>) { + const config = scopeConfig[scope]; + const Icon = config.icon; + const label = config.label(); + return ( + + + + + {label} + + ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index 1809a70f8..19ad53f7e 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -92,8 +92,9 @@ export function TenantOverrideRow({ return ( - {tenant.tenantId} + {tenant.tenantId} {tenant.tenantName} + {tenant.plan} {getSourceLabel(tenant.source)} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 77a6bb877..084336b3f 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -129,12 +129,15 @@ function TenantTable({
- + Account ID Account + + Plan + Source diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx new file mode 100644 index 000000000..eb69f497a --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx @@ -0,0 +1,22 @@ +import { t } from "@lingui/core/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SearchIcon } from "lucide-react"; + +export function UserEmptyState({ variant }: Readonly<{ variant: "no-users" | "no-results" }>) { + const title = variant === "no-users" ? t`No user overrides` : t`No users found`; + const description = + variant === "no-users" + ? t`Use the search above to find users and manage their overrides` + : t`Try adjusting your search`; + return ( + + + + + + {title} + {description} + + + ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 6cdb51385..ee2570a4e 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -9,6 +9,7 @@ import type { BucketRange } from "./rolloutBucket"; import type { FlagUserInfo } from "./types"; import { sortBySourceThenBucket } from "./rolloutBucket"; +import { UserEmptyState } from "./UserEmptyState"; import { UserOverrideRow } from "./UserOverrideRow"; export function UserOverridesSection({ @@ -38,29 +39,26 @@ export function UserOverridesSection({ : users; }, [users, search]); - const enabledUsers = useMemo( - () => - sortBySourceThenBucket( - filtered.filter((u) => u.isEnabled), + const { enabledUsers, disabledUsers } = useMemo(() => { + const enabled = filtered.filter((u) => u.isEnabled); + const disabled = filtered.filter((u) => !u.isEnabled); + return { + enabledUsers: sortBySourceThenBucket( + enabled, (u) => u.source, (u) => u.userId, "enabled", bucketRange ), - [filtered, bucketRange] - ); - - const disabledUsers = useMemo( - () => - sortBySourceThenBucket( - filtered.filter((u) => !u.isEnabled), + disabledUsers: sortBySourceThenBucket( + disabled, (u) => u.source, (u) => u.userId, "disabled", bucketRange - ), - [filtered, bucketRange] - ); + ) + }; + }, [filtered, bucketRange]); const isSearching = search.length > 0; @@ -77,15 +75,19 @@ export function UserOverridesSection({ className="max-w-[20rem]" /> {isSearching ? ( - - ) : ( + filtered.length > 0 ? ( + + ) : ( + + ) + ) : users.length > 0 ? ( <> + ) : ( + )} ); diff --git a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts index a50b8f84d..5f19fb27c 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts @@ -30,6 +30,10 @@ function getKnownFlagLabels(): Record { "compact-view": { name: t`Compact view`, description: t`Reduce spacing between UI elements for a denser layout` + }, + "experimental-ui": { + name: t`Experimental UI`, + description: t`Try out experimental user interface components` } }; } diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 853a8cfc7..226cc4f0b 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -24,6 +24,7 @@ export interface GetFeatureFlagsResponse { export interface FlagTenantInfo { tenantId: string; tenantName: string; + plan: string; isEnabled: boolean; source: "manual_override" | "ab_rollout" | "default"; rolloutBucket: number; diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index c0801db18..de4315427 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -14,6 +14,7 @@ import { api } from "@/shared/lib/api/client"; import type { FeatureFlagInfo, FeatureFlagScope, GetFeatureFlagsResponse } from "./-components/types"; import { getFlagDescription, getFlagName } from "./-components/flagLabels"; +import { ScopeIcon } from "./-components/ScopeIcon"; export const Route = createFileRoute("/feature-flags/")({ staticData: { trackingTitle: "Feature flags" }, @@ -101,7 +102,10 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { >
- {getFlagName(flag.key)} + + + {getFlagName(flag.key)} + {getFlagDescription(flag.key) || flag.description} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 97bec0232..73b915f3f 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -366,6 +366,9 @@ msgstr "" msgid "Exit kiosk mode" msgstr "" +msgid "Experimental UI" +msgstr "" + msgid "Expired" msgstr "" @@ -609,9 +612,15 @@ msgstr "Ingen login-forsøg de seneste 30 dage." msgid "No transactions" msgstr "" +msgid "No user overrides" +msgstr "" + msgid "No users" msgstr "Ingen brugere" +msgid "No users found" +msgstr "" + msgid "No users match your filters." msgstr "Ingen brugere matcher dine filtre." @@ -1031,6 +1040,9 @@ msgstr "Omsætning i alt" msgid "Try a different search term or clear the role and activity filters." msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." +msgid "Try adjusting your search" +msgstr "" + msgid "Try again" msgstr "Prøv igen" @@ -1058,6 +1070,9 @@ msgstr "Ukendt" msgid "Upgraded" msgstr "Opgraderet" +msgid "Use the search above to find users and manage their overrides" +msgstr "" + msgid "User" msgstr "Bruger" diff --git a/application/account/WebApp/shared/lib/api/featureFlagLabels.ts b/application/account/WebApp/shared/lib/api/featureFlagLabels.ts index b19430e99..7a6b1ffc2 100644 --- a/application/account/WebApp/shared/lib/api/featureFlagLabels.ts +++ b/application/account/WebApp/shared/lib/api/featureFlagLabels.ts @@ -14,6 +14,10 @@ function getKnownFlagLabels(): Record { "compact-view": { name: t`Compact view`, description: t`Use a more compact layout` + }, + "experimental-ui": { + name: t`Experimental UI`, + description: t`Try experimental UI components` } }; } diff --git a/application/account/WebApp/shared/translations/locale/da-DK.po b/application/account/WebApp/shared/translations/locale/da-DK.po index 222f19180..277ece523 100644 --- a/application/account/WebApp/shared/translations/locale/da-DK.po +++ b/application/account/WebApp/shared/translations/locale/da-DK.po @@ -1135,6 +1135,9 @@ msgstr "Fold Komponenter ud" msgid "Expand Examples" msgstr "Fold Eksempler ud" +msgid "Experimental UI" +msgstr "Eksperimentel brugerflade" + msgid "expires" msgstr "udløber" @@ -2759,6 +2762,9 @@ msgstr "Prøv at justere din søgning eller filtre" msgid "Try again" msgstr "Prøv igen" +msgid "Try experimental UI components" +msgstr "Prøv eksperimentelle brugerfladekomponenter" + msgid "Two-factor authentication" msgstr "Tofaktor-godkendelse" diff --git a/application/account/WebApp/shared/translations/locale/en-US.po b/application/account/WebApp/shared/translations/locale/en-US.po index 52b11bd06..242508db3 100644 --- a/application/account/WebApp/shared/translations/locale/en-US.po +++ b/application/account/WebApp/shared/translations/locale/en-US.po @@ -1135,6 +1135,9 @@ msgstr "Expand Components" msgid "Expand Examples" msgstr "Expand Examples" +msgid "Experimental UI" +msgstr "Experimental UI" + msgid "expires" msgstr "expires" @@ -2759,6 +2762,9 @@ msgstr "Try adjusting your search or filters" msgid "Try again" msgstr "Try again" +msgid "Try experimental UI components" +msgstr "Try experimental UI components" + msgid "Two-factor authentication" msgstr "Two-factor authentication" From 2281f97927056b9bc3577857877b12f610ddfbdf Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:03:46 +0200 Subject: [PATCH 028/155] Fix GetFlagUsers to return all users with computed flag state --- .../FeatureFlagEvaluationService.cs | 21 ++----------- .../FeatureFlags/Queries/GetFlagUsers.cs | 31 ++++++++++++------- .../Features/Users/Domain/UserRepository.cs | 5 ++- .../Tests/FeatureFlags/FeatureFlagTests.cs | 20 ++++-------- .../FeatureFlags/RolloutBucketHasher.cs | 17 ++++++++++ 5 files changed, 48 insertions(+), 46 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs b/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs index ebf37ec5f..1a1c7c3a4 100644 --- a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs +++ b/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs @@ -54,7 +54,7 @@ private static bool EvaluateTenantScope(FeatureFlagDefinition definition, Featur if (definition.IsAbTestEligible && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) { - return IsInBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return RolloutBucketHasher.IsInBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); } return false; @@ -72,7 +72,7 @@ private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureF if (definition.IsAbTestEligible && userRolloutBucket is not null && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) { - return IsInBucketRange(userRolloutBucket.Value, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return RolloutBucketHasher.IsInBucketRange(userRolloutBucket.Value, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); } return false; @@ -83,23 +83,6 @@ private static bool IsActive(FeatureFlag flag) return flag.EnabledAt is not null && (flag.DisabledAt is null || flag.EnabledAt > flag.DisabledAt); } - private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) - { - // Bucket 0 = always opt-in (internal testers), included in any rollout - if (bucket == 0) return true; - - // Bucket 100 = always opt-out (VIP customers), only included at 100% rollout - if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; - - if (bucketStart <= bucketEnd) - { - return bucket >= bucketStart && bucket <= bucketEnd; - } - - // Wrap-around case (e.g., start=90, end=10 means 90-99 and 1-10) - return bucket >= bucketStart || bucket <= bucketEnd; - } - private static FeatureFlagDefinition[] TopologicalSort(FeatureFlagDefinition[] definitions) { var result = new List(definitions.Length); diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs index ad9149198..777e8101b 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs @@ -49,26 +49,33 @@ public async Task> Handle(GetFlagUsersQuery query, var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); + var users = await userRepository.GetAllUnfilteredAsync(cancellationToken); var userOverrides = await featureFlagRepository.GetUserOverridesForFlagAsync(query.FlagKey, cancellationToken); - if (userOverrides.Length == 0) return new GetFlagUsersResponse([]); + var overridesByUserId = userOverrides.ToDictionary(f => f.UserId!); - var userIds = userOverrides.Select(f => new UserId(f.UserId!)).ToArray(); - var users = await userRepository.GetByIdsUnfilteredAsync(userIds, cancellationToken); - var usersById = users.ToDictionary(u => u.Id.Value); + var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(query.FlagKey, null, null, cancellationToken); var tenantIds = users.Select(u => u.TenantId).Distinct().ToArray(); var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); var tenantsById = tenants.ToDictionary(t => t.Id); - var flagUsers = userOverrides.Select(overrideRow => + var flagUsers = users.Select(user => { - var userId = new UserId(overrideRow.UserId!); - var user = usersById.GetValueOrDefault(userId.Value); - var tenantName = user is not null && tenantsById.TryGetValue(user.TenantId, out var tenant) ? tenant.Name : "Unknown"; - var isEnabled = overrideRow.EnabledAt is not null && (overrideRow.DisabledAt is null || overrideRow.EnabledAt > overrideRow.DisabledAt); - var tenantId = user?.TenantId ?? new TenantId(0); - var rolloutBucket = user?.RolloutBucket ?? 0; - return new FlagUserInfo(userId, tenantId, user?.Email ?? "Unknown", tenantName, rolloutBucket, isEnabled, "manual_override"); + var tenantName = tenantsById.TryGetValue(user.TenantId, out var tenant) ? tenant.Name : "Unknown"; + + if (overridesByUserId.TryGetValue(user.Id.Value, out var userOverride)) + { + var isEnabled = userOverride.EnabledAt is not null && (userOverride.DisabledAt is null || userOverride.EnabledAt > userOverride.DisabledAt); + return new FlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isEnabled, "manual_override"); + } + + if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + var isInRange = RolloutBucketHasher.IsInBucketRange(user.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return new FlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isInRange, "ab_rollout"); + } + + return new FlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, false, "default"); } ).ToArray(); diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 23d43c181..4727da5b5 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -526,7 +526,10 @@ public async Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, C /// public async Task GetAllUnfilteredAsync(CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .OrderBy(u => u.Id) + .ToArrayAsync(cancellationToken); } /// diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index d75467058..3a79097b5 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -465,7 +465,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldRetur } [Fact] - public async Task GetFlagUsers_WhenUserScopedFlag_ShouldReturnEmptyWhenNoOverrides() + public async Task GetFlagUsers_WhenUserScopedFlag_ShouldReturnAllUsersWithDefaultSource() { // Arrange var flagKey = "compact-view"; @@ -477,7 +477,8 @@ public async Task GetFlagUsers_WhenUserScopedFlag_ShouldReturnEmptyWhenNoOverrid response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Users.Should().BeEmpty(); + result.Users.Should().NotBeEmpty(); + result.Users.Should().OnlyContain(u => u.Source == "default"); } [Fact] @@ -511,9 +512,8 @@ public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOve response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Users.Should().HaveCount(1); - var userResult = result.Users[0]; - userResult.UserId.Value.Should().Be(userId); + result.Users.Should().NotBeEmpty(); + var userResult = result.Users.Single(u => u.UserId.Value == userId); userResult.IsEnabled.Should().BeTrue(); userResult.Source.Should().Be("manual_override"); userResult.Email.Should().NotBe("Unknown"); @@ -1102,15 +1102,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllT private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) { - if (bucket == 0) return true; - if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; - - if (bucketStart <= bucketEnd) - { - return bucket >= bucketStart && bucket <= bucketEnd; - } - - return bucket >= bucketStart || bucket <= bucketEnd; + return RolloutBucketHasher.IsInBucketRange(bucket, bucketStart, bucketEnd); } private static int CountBucketsInRange(int bucketStart, int bucketEnd) diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs index 7c3cf5ac2..707136cea 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs @@ -19,4 +19,21 @@ public static int ComputeBucket(string entityId) return (int)(hash % 99 + 1); } } + + public static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) + { + // Bucket 0 = always opt-in (internal testers), included in any rollout + if (bucket == 0) return true; + + // Bucket 100 = always opt-out (VIP customers), only included at 100% rollout + if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; + + if (bucketStart <= bucketEnd) + { + return bucket >= bucketStart && bucket <= bucketEnd; + } + + // Wrap-around case (e.g., start=90, end=10 means 90-99 and 1-10) + return bucket >= bucketStart || bucket <= bucketEnd; + } } From 021f53c2c2c83cfcd8243e62d3d30f62a727d301 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:14:22 +0200 Subject: [PATCH 029/155] Deduplicate bucket range logic and add plan to tenant info --- .../FeatureFlags/Queries/GetFlagTenants.cs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs index e29711fef..e9bd76b57 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs @@ -63,7 +63,7 @@ public async Task> Handle(GetFlagTenantsQuery que if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { - var isInRange = IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + var isInRange = RolloutBucketHasher.IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isInRange, "ab_rollout"); } @@ -73,21 +73,4 @@ public async Task> Handle(GetFlagTenantsQuery que return new GetFlagTenantsResponse(flagTenants); } - - private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) - { - // Bucket 0 = always opt-in (internal testers), included in any rollout - if (bucket == 0) return true; - - // Bucket 100 = always opt-out (VIP customers), only included at 100% rollout - if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; - - if (bucketStart <= bucketEnd) - { - return bucket >= bucketStart && bucket <= bucketEnd; - } - - // Wrap-around case (e.g., start=90, end=10 means 90-99 and 1-10) - return bucket >= bucketStart || bucket <= bucketEnd; - } } From daa40abd13cc170de98ec56c0657c4a33ca272b7 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:15:14 +0200 Subject: [PATCH 030/155] Refine feature flag detail layout with 3-line metadata, scope icons in headers, and mobile responsive columns --- .../routes/feature-flags/$flagKey.tsx | 12 +--- .../-components/FlagInfoSection.tsx | 67 +++++++++++-------- .../-components/TenantOverrideRow.tsx | 6 +- .../-components/TenantOverridesSection.tsx | 4 +- .../-components/UserOverrideRow.tsx | 6 +- .../-components/UserOverridesSection.tsx | 4 +- .../routes/feature-flags/-components/types.ts | 2 +- .../BackOffice/routes/feature-flags/index.tsx | 18 ++--- .../shared/translations/locale/da-DK.po | 28 +++++++- .../tests/e2e/feature-flag-flows.spec.ts | 2 +- 10 files changed, 91 insertions(+), 58 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 574480903..98a9b62b2 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -13,6 +13,7 @@ import type { GetFeatureFlagsResponse, GetFlagTenantsResponse, GetFlagUsersRespo import { FlagInfoSection } from "./-components/FlagInfoSection"; import { getFlagDescription, getFlagName } from "./-components/flagLabels"; +import { ScopeIcon } from "./-components/ScopeIcon"; import { TenantOverridesSection } from "./-components/TenantOverridesSection"; import { UserOverridesSection } from "./-components/UserOverridesSection"; @@ -69,18 +70,11 @@ export default function FlagDetailPage() { + {flag && } {flagName}
} - subtitle={ - flag ? ( - - {description} -
- {flag.key} -
- ) : undefined - } + subtitle={flag ? description : undefined} > {isLoading ? ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx index 2d137598b..936c45aee 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx @@ -14,7 +14,6 @@ import type { FeatureFlagInfo } from "./types"; import { getFlagName } from "./flagLabels"; import { formatBucketRange } from "./rolloutBucket"; -import { ScopeIcon } from "./ScopeIcon"; export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); @@ -35,9 +34,9 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { return (
-
+
-
+
{flag.isAbTestEligible && ( )} @@ -50,12 +49,35 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { />
- {flag.bucketStart != null && flag.bucketEnd != null && flag.rolloutPercentage != null && ( -
- {formatBucketRange(flag.bucketStart, flag.bucketEnd, flag.rolloutPercentage)} +
+ ); +} + +function FlagMetadata({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { + const enabledLine = + flag.enabledAt && flag.disabledAt + ? t`Enabled period: ${formatTimestamp(flag.enabledAt)} - ${formatTimestamp(flag.disabledAt)}` + : flag.enabledAt + ? t`Enabled: ${formatTimestamp(flag.enabledAt)}` + : null; + + const bucketLine = + flag.isAbTestEligible && flag.bucketStart != null && flag.bucketEnd != null && flag.rolloutPercentage != null + ? formatBucketRange(flag.bucketStart, flag.bucketEnd, flag.rolloutPercentage) + : null; + + return ( +
+ + Name: {flag.key} + + {enabledLine && {enabledLine}} + {bucketLine && ( + + {bucketLine} - + @@ -64,34 +86,21 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { -
- )} -
- ); -} - -function FlagMetadata({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { - return ( - - - {flag.enabledAt && ( - - {t`Enabled`}: {formatTimestamp(flag.enabledAt)} - - )} - {flag.enabledAt && flag.disabledAt && {"\u00B7"}} - {flag.disabledAt && ( - - {t`Disabled`}: {formatTimestamp(flag.disabledAt)} )} - +
); } function formatTimestamp(isoDate: string): string { const date = new Date(isoDate); - return new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "long", day: "numeric" }).format(date); + return new Intl.DateTimeFormat(navigator.language, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit" + }).format(date); } function RolloutPercentageInput({ @@ -133,7 +142,7 @@ function RolloutPercentageInput({ return ( {tenant.tenantId} {tenant.tenantName} {tenant.plan} - + {getSourceLabel(tenant.source)} - {showBucket && {tenant.rolloutBucket}} + {showBucket && ( + {tenant.rolloutBucket} + )}
{tenant.source === "manual_override" && ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 084336b3f..93269b835 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -138,11 +138,11 @@ function TenantTable({ Plan - + Source {showBucket && ( - + Bucket )} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index 8df829c67..18872be2f 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -17,6 +17,8 @@ function getSourceLabel(source: string): string { switch (source) { case "manual_override": return t`Manual override`; + case "ab_rollout": + return t`A/B rollout`; case "default": return t`Default`; default: @@ -104,10 +106,10 @@ export function UserOverrideRow({ {user.email} {user.tenantName} - + {getSourceLabel(user.source)} - {showBucket && {user.rolloutBucket}} + {showBucket && {user.rolloutBucket}}
{user.source === "manual_override" && ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index ee2570a4e..3abe6aadf 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -133,11 +133,11 @@ function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket, isF Account - + Source {showBucket && ( - + Bucket )} diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 226cc4f0b..5315a43f3 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -40,7 +40,7 @@ export interface FlagUserInfo { email: string; tenantName: string; isEnabled: boolean; - source: "manual_override" | "default"; + source: "manual_override" | "ab_rollout" | "default"; rolloutBucket: number; } diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index de4315427..92530d7a7 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -72,7 +72,10 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { const showRollout = group.scope !== "System"; return (
-

{group.label}

+

+ + {group.label} +

@@ -80,11 +83,11 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { Name {showRollout && ( - + Rollout )} - + Status @@ -102,17 +105,14 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { >
- - - {getFlagName(flag.key)} - + {getFlagName(flag.key)} {getFlagDescription(flag.key) || flag.description}
{showRollout && ( - + {flag.rolloutPercentage !== null ? ( `${flag.rolloutPercentage}%` ) : ( @@ -120,7 +120,7 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { )} )} - + {flag.isActive ? t`Active` : t`Inactive`} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 73b915f3f..bbb3c8699 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -346,7 +346,21 @@ msgid "Email pending" msgstr "E-mail afventer" msgid "Enabled" -msgstr "" +msgstr "Aktiveret" + +#. placeholder {0}: enabledTenants.length +#. placeholder {0}: enabledUsers.length +msgid "Enabled ({0})" +msgstr "Aktiveret ({0})" + +#. placeholder {0}: formatTimestamp(flag.enabledAt) +#. placeholder {1}: formatTimestamp(flag.disabledAt) +msgid "Enabled period: {0} - {1}" +msgstr "Aktiveret periode: {0} - {1}" + +#. placeholder {0}: formatTimestamp(flag.enabledAt) +msgid "Enabled: {0}" +msgstr "Aktiveret: {0}" msgid "Enter kiosk mode" msgstr "" @@ -516,6 +530,9 @@ msgstr "MRR-tendens" msgid "Name" msgstr "Navn" +msgid "Name:" +msgstr "" + msgid "Navigation" msgstr "Navigation" @@ -850,6 +867,15 @@ msgstr "Udrulningsspande: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPerce msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" msgstr "Udrulningsspande: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgid "Rollout %" +msgstr "Udrulnings-%" + +msgid "Rollout buckets: {bucketStart}-{bucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsbuckets: {bucketStart}-{bucketEnd} ({rolloutPercentage}%)" + +msgid "Rollout buckets: {bucketStart}-100 and 1-{bucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsbuckets: {bucketStart}-100 og 1-{bucketEnd} ({rolloutPercentage}%)" + msgid "Rollout percentage" msgstr "Udrulningsprocent" diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index dc8de9783..2e8a46436 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -85,7 +85,7 @@ test.describe("@smoke", () => { })(); await step("Set A/B rollout percentage & verify success toast on blur")(async () => { - const percentageInput = page.getByRole("spinbutton", { name: "Rollout percentage" }); + const percentageInput = page.getByRole("spinbutton", { name: "Rollout %" }); const newValue = String(10 + (Date.now() % 80)); await percentageInput.fill(newValue); await blurActiveElement(page); From 10888081dd218f9c430eb1c6ab0371c63f993847 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:32:17 +0200 Subject: [PATCH 031/155] Clear disabled_at when reactivating a feature flag --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 4 +-- .../Api/Endpoints/FeatureFlagEndpoints.cs | 4 +-- .../FeatureFlags/Domain/FeatureFlag.cs | 2 ++ .../FeatureFlags/Queries/GetFlagUsers.cs | 8 +++++- .../Features/Users/Domain/UserRepository.cs | 20 ++++++++++++++ .../Tests/FeatureFlags/FeatureFlagTests.cs | 27 ++++++++++++++++--- 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index e870a930c..c941a3e33 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -51,8 +51,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - group.MapGet("/{flagKey}/users", async Task> (string flagKey, IMediator mediator) - => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey }) + group.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFlagUsersQuery query, IMediator mediator) + => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index accaa678a..46f412bd9 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -41,8 +41,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, IMediator mediator) - => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey }) + internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFlagUsersQuery query, IMediator mediator) + => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); internalGroup.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index b6c95a73e..588cd2b31 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -58,11 +58,13 @@ public static FeatureFlag CreateUserOverride(string flagKey, long tenantId, stri public void Activate(DateTimeOffset now) { EnabledAt = now; + DisabledAt = null; } public void Deactivate(DateTimeOffset now) { DisabledAt = now; + EnabledAt = null; } public void SetRolloutRange(int? bucketStart, int? bucketEnd) diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs index 777e8101b..d888b40bd 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs @@ -14,6 +14,8 @@ public sealed record GetFlagUsersQuery : IRequest> { [JsonIgnore] // Removes from API contract public string FlagKey { get; init; } = null!; + + public string? Search { get; init; } } [PublicAPI] @@ -38,6 +40,8 @@ public GetFlagUsersValidator() .NotEmpty().WithMessage("Flag key must not be empty.") .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + + RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters."); } } @@ -49,7 +53,9 @@ public async Task> Handle(GetFlagUsersQuery query, var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); - var users = await userRepository.GetAllUnfilteredAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(query.Search)) return new GetFlagUsersResponse([]); + + var users = await userRepository.SearchByEmailUnfilteredAsync(query.Search.Trim(), cancellationToken); var userOverrides = await featureFlagRepository.GetUserOverridesForFlagAsync(query.FlagKey, cancellationToken); var overridesByUserId = userOverrides.ToDictionary(f => f.UserId!); diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 4727da5b5..7cc2ea9e3 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -34,6 +34,12 @@ public interface IUserRepository : ICrudRepository, IBulkRemoveRep /// Task GetByIdsUnfilteredAsync(UserId[] ids, CancellationToken cancellationToken); + /// + /// Searches users by email without applying tenant query filters. + /// This method should only be used for internal back-office operations that need cross-tenant access. + /// + Task SearchByEmailUnfilteredAsync(string search, CancellationToken cancellationToken); + Task GetDeletedByIdsAsync(UserId[] ids, CancellationToken cancellationToken); Task<(User[] Users, int TotalItems, int TotalPages)> Search( @@ -183,6 +189,20 @@ public async Task GetByIdsUnfilteredAsync(UserId[] ids, CancellationToke .ToArrayAsync(cancellationToken); } + /// + /// Searches users by email without applying tenant query filters. + /// This method should only be used for internal back-office operations that need cross-tenant access. + /// + public async Task SearchByEmailUnfilteredAsync(string search, CancellationToken cancellationToken) + { + var lowerSearch = search.ToLowerInvariant(); + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.Email.Contains(lowerSearch)) + .OrderBy(u => u.Id) + .ToArrayAsync(cancellationToken); + } + public async Task GetDeletedByIdsAsync(UserId[] ids, CancellationToken cancellationToken) { return await DbSet diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 3a79097b5..14961d367 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -81,7 +81,11 @@ public async Task DeactivateFeatureFlag_WhenActive_ShouldSetDisabledAt() var disabledAt = Connection.ExecuteScalar( "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); disabledAt.Should().NotBeNullOrEmpty(); + enabledAt.Should().BeNull("EnabledAt should be cleared on deactivation"); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagDeactivated"); @@ -127,8 +131,7 @@ public async Task ActivateFeatureFlag_AfterDeactivation_ShouldReactivateFlag() "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); enabledAt.Should().NotBeNullOrEmpty(); - disabledAt.Should().NotBeNullOrEmpty(); - string.Compare(enabledAt, disabledAt, StringComparison.Ordinal).Should().BeGreaterThan(0, "EnabledAt should be after DisabledAt"); + disabledAt.Should().BeNull("DisabledAt should be cleared on reactivation"); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); @@ -465,7 +468,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldRetur } [Fact] - public async Task GetFlagUsers_WhenUserScopedFlag_ShouldReturnAllUsersWithDefaultSource() + public async Task GetFlagUsers_WhenNoSearchProvided_ShouldReturnEmptyArray() { // Arrange var flagKey = "compact-view"; @@ -473,6 +476,22 @@ public async Task GetFlagUsers_WhenUserScopedFlag_ShouldReturnAllUsersWithDefaul // Act var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().BeEmpty(); + } + + [Fact] + public async Task GetFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchingUsersWithDefaultSource() + { + // Arrange + var flagKey = "compact-view"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?search=owner@tenant-1"); + // Assert response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); @@ -506,7 +525,7 @@ public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOve ); // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?search=owner@tenant-1"); // Assert response.ShouldBeSuccessfulGetRequest(); From 4a51beec627d126f2ec4ed441af0fd6abbde9aa4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:39:02 +0200 Subject: [PATCH 032/155] Clean up unused repository methods --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 4 ++-- .../Api/Endpoints/FeatureFlagEndpoints.cs | 4 ++-- .../WebApp/tests/e2e/feature-flag-flows.spec.ts | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index c941a3e33..07cbdbdf9 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -51,8 +51,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - group.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFlagUsersQuery query, IMediator mediator) - => await mediator.Send(query with { FlagKey = flagKey }) + group.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) + => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey, Search = search }) ).Produces(); group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 46f412bd9..072fa63e9 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -41,8 +41,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFlagUsersQuery query, IMediator mediator) - => await mediator.Send(query with { FlagKey = flagKey }) + internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) + => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey, Search = search }) ).Produces(); internalGroup.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 2e8a46436..144f32c68 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -100,6 +100,22 @@ test.describe("@smoke", () => { await expect(page.getByRole("heading", { name: "Feature flags" })).toBeVisible(); })(); + await step("Activate custom branding flag globally via back-office API for downstream checks")(async () => { + const response = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/custom-branding/activate` + ); + + expect(response.ok()).toBe(true); + })(); + + await step("Activate compact view flag globally via back-office API for downstream checks")(async () => { + const response = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/compact-view/activate` + ); + + expect(response.ok()).toBe(true); + })(); + await backOfficeContext.close(); // === ACCOUNT SETTINGS: TENANT FEATURE FLAGS === From 7ebdf0eb02383ce93950fcb67302f31494cb284a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:39:26 +0200 Subject: [PATCH 033/155] Complete feature flag UI polish with server-side user search, responsive layout, and visual refinements --- .../routes/feature-flags/$flagKey.tsx | 24 ++---- .../-components/FlagInfoSection.tsx | 2 +- .../-components/TenantOverrideRow.tsx | 4 +- .../-components/TenantOverridesSection.tsx | 8 +- .../-components/UserEmptyState.tsx | 17 ++++- .../-components/UserOverrideRow.tsx | 4 +- .../-components/UserOverridesSection.tsx | 73 ++++++++++--------- .../BackOffice/routes/feature-flags/index.tsx | 12 +-- .../shared/translations/locale/da-DK.po | 21 ++++-- 9 files changed, 86 insertions(+), 79 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 98a9b62b2..7b6bb50c2 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -2,14 +2,13 @@ import { t } from "@lingui/core/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; -import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeftIcon } from "lucide-react"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; -import { api, apiClient } from "@/shared/lib/api/client"; +import { api } from "@/shared/lib/api/client"; -import type { GetFeatureFlagsResponse, GetFlagTenantsResponse, GetFlagUsersResponse } from "./-components/types"; +import type { GetFeatureFlagsResponse, GetFlagTenantsResponse } from "./-components/types"; import { FlagInfoSection } from "./-components/FlagInfoSection"; import { getFlagDescription, getFlagName } from "./-components/flagLabels"; @@ -42,20 +41,7 @@ export default function FlagDetailPage() { isLoading: boolean; }; - const { data: usersData, isLoading: isLoadingUsers } = useQuery({ - queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users", { params: { path: { flagKey } } }], - queryFn: async () => { - // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec - const { data } = await apiClient.GET("/api/back-office/feature-flags/{flagKey}/users" as any, { - params: { path: { flagKey } } - }); - return data as GetFlagUsersResponse | undefined; - }, - enabled: flag?.scope === "User" - }); - - const isLoading = - isLoadingFlags || (flag?.scope === "Tenant" && isLoadingTenants) || (flag?.scope === "User" && isLoadingUsers); + const isLoading = isLoadingFlags || (flag?.scope === "Tenant" && isLoadingTenants); const flagName = flag ? getFlagName(flag.key) : flagKey; const description = flag ? getFlagDescription(flag.key) || flag.description : ""; @@ -64,13 +50,14 @@ export default function FlagDetailPage() { - {flag && } + {flag && } {flagName} } @@ -99,7 +86,6 @@ export default function FlagDetailPage() { ) { return (
-
+
{flag.isAbTestEligible && ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index 73f5e3463..bc45e4171 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -92,8 +92,8 @@ export function TenantOverrideRow({ return ( - {tenant.tenantId} - {tenant.tenantName} + {tenant.tenantId} + {tenant.tenantName} {tenant.plan} {getSourceLabel(tenant.source)} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 93269b835..df4ad7321 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -126,16 +126,16 @@ function TenantTable({ isFlagActive }: Readonly) { return ( -
+
- + Account ID - + Account - + Plan diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx index eb69f497a..c754e9316 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx @@ -1,12 +1,23 @@ import { t } from "@lingui/core/macro"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; import { SearchIcon } from "lucide-react"; -export function UserEmptyState({ variant }: Readonly<{ variant: "no-users" | "no-results" }>) { - const title = variant === "no-users" ? t`No user overrides` : t`No users found`; +export function UserEmptyState({ variant }: Readonly<{ variant: "no-users" | "no-results" | "loading" }>) { + if (variant === "loading") { + return ( +
+ + + +
+ ); + } + + const title = variant === "no-users" ? t`Search for users` : t`No users found`; const description = variant === "no-users" - ? t`Use the search above to find users and manage their overrides` + ? t`Type an email address above to find users and manage their overrides` : t`Try adjusting your search`; return ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index 18872be2f..59914039a 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -104,8 +104,8 @@ export function UserOverrideRow({ return ( - {user.email} - {user.tenantName} + {user.email} + {user.tenantName} {getSourceLabel(user.source)} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 3abe6aadf..8e122bf87 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -2,11 +2,14 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; +import { useQuery } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { apiClient } from "@/shared/lib/api/client"; import type { BucketRange } from "./rolloutBucket"; -import type { FlagUserInfo } from "./types"; +import type { FlagUserInfo, GetFlagUsersResponse } from "./types"; import { sortBySourceThenBucket } from "./rolloutBucket"; import { UserEmptyState } from "./UserEmptyState"; @@ -15,33 +18,40 @@ import { UserOverrideRow } from "./UserOverrideRow"; export function UserOverridesSection({ flagKey, flagDescription, - users, showBucket, bucketRange, isFlagActive }: Readonly<{ flagKey: string; flagDescription: string; - users: FlagUserInfo[]; showBucket: boolean; bucketRange: BucketRange | null; isFlagActive: boolean; }>) { const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); - const filtered = useMemo(() => { - const lowerSearch = search.toLowerCase(); - return search - ? users.filter( - (user) => - user.email.toLowerCase().includes(lowerSearch) || user.tenantName.toLowerCase().includes(lowerSearch) - ) - : users; - }, [users, search]); + const { data: usersData, isLoading } = useQuery({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users", { flagKey, search: debouncedSearch }], + queryFn: async () => { + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec + const { data } = await apiClient.GET("/api/back-office/feature-flags/{flagKey}/users" as any, { + params: { path: { flagKey }, query: { search: debouncedSearch } } + }); + return data as GetFlagUsersResponse | undefined; + }, + enabled: debouncedSearch.length > 0 + }); const { enabledUsers, disabledUsers } = useMemo(() => { - const enabled = filtered.filter((u) => u.isEnabled); - const disabled = filtered.filter((u) => !u.isEnabled); + const all = usersData?.users ?? []; + const enabled = all.filter((u) => u.isEnabled); + const disabled = all.filter((u) => !u.isEnabled); return { enabledUsers: sortBySourceThenBucket( enabled, @@ -58,9 +68,9 @@ export function UserOverridesSection({ bucketRange ) }; - }, [filtered, bucketRange]); + }, [usersData?.users, bucketRange]); - const isSearching = search.length > 0; + const hasSearched = debouncedSearch.length > 0; return (
@@ -69,25 +79,16 @@ export function UserOverridesSection({ setSearch(value)} className="max-w-[20rem]" /> - {isSearching ? ( - filtered.length > 0 ? ( - - ) : ( - - ) - ) : users.length > 0 ? ( + {!hasSearched ? ( + + ) : isLoading ? ( + + ) : enabledUsers.length + disabledUsers.length > 0 ? ( <> ) : ( - + )}
); @@ -124,13 +125,13 @@ interface UserTableProps { function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket, isFlagActive }: Readonly) { return ( -
+
- + Email - + Account diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index 92530d7a7..455b61351 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -54,7 +54,7 @@ export default function FeatureFlagsPage() { - + {isLoading ? : } @@ -72,10 +72,7 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { const showRollout = group.scope !== "System"; return (
-

- - {group.label} -

+

{group.label}

@@ -105,7 +102,10 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { >
- {getFlagName(flag.key)} + + + {getFlagName(flag.key)} + {getFlagDescription(flag.key) || flag.description} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index bbb3c8699..3b8ecb29b 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -629,9 +629,6 @@ msgstr "Ingen login-forsøg de seneste 30 dage." msgid "No transactions" msgstr "" -msgid "No user overrides" -msgstr "" - msgid "No users" msgstr "Ingen brugere" @@ -903,6 +900,12 @@ msgstr "Søg" msgid "Search by account name" msgstr "" +msgid "Search by account name or ID" +msgstr "Søg efter kontonavn eller ID" + +msgid "Search by email" +msgstr "Søg efter e-mail" + msgid "Search by email, name, or account" msgstr "Søg på e-mail, navn eller konto" @@ -915,6 +918,12 @@ msgstr "Søg efter navn eller e-mail" msgid "Search by tenant name or ID" msgstr "Søg efter lejernavn eller ID" +msgid "Search for users" +msgstr "Søg efter brugere" + +msgid "Search results" +msgstr "Søgeresultater" + msgid "Search users" msgstr "Søg brugere" @@ -1087,6 +1096,9 @@ msgstr "Type" msgid "Type an email address above to find users and manage their overrides" msgstr "Indtast en e-mailadresse ovenfor for at finde brugere og administrere deres tilsidesættelser" +msgid "Type an email address above to find users and manage their overrides" +msgstr "" + msgid "Unclassified" msgstr "Uklassificeret" @@ -1096,9 +1108,6 @@ msgstr "Ukendt" msgid "Upgraded" msgstr "Opgraderet" -msgid "Use the search above to find users and manage their overrides" -msgstr "" - msgid "User" msgstr "Bruger" From 49ff8c7e3e1cb3897118d9620665480872ec264d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 13:45:07 +0200 Subject: [PATCH 034/155] Add empty states and descriptive subtitles to feature flag detail pages --- .../-components/TenantOverridesSection.tsx | 27 ++++++++++++++++--- .../-components/UserOverridesSection.tsx | 18 ++++++++++--- .../shared/translations/locale/da-DK.po | 15 +++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index df4ad7321..da33dfa0d 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -65,9 +65,21 @@ export function TenantOverridesSection({ return (
-

- Account status -

+
+

+ Account status +

+

+ {showBucket ? ( + + Accounts are automatically included based on their rollout bucket. Use overrides to manually include or + exclude specific accounts. + + ) : ( + Toggle the override switch to enable this feature for specific accounts. + )} +

+

{label}

- {isOpen && } + {isOpen && + (tableProps.tenants.length > 0 ? ( + + ) : ( +

+ No accounts in this group. +

+ ))}
); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 8e122bf87..5fb1517f3 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -74,9 +74,21 @@ export function UserOverridesSection({ return (
-

- User status -

+
+

+ User status +

+

+ {showBucket ? ( + + Users are automatically included based on their rollout bucket. Use overrides to manually include or + exclude specific users. + + ) : ( + Search for users by email and toggle the override switch to enable this feature. + )} +

+
Date: Sat, 4 Apr 2026 15:02:14 +0200 Subject: [PATCH 035/155] Hide flag description on mobile list page for better status visibility --- application/account/BackOffice/routes/feature-flags/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index 455b61351..acd26d9cd 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -106,7 +106,7 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { {getFlagName(flag.key)} - + {getFlagDescription(flag.key) || flag.description}
From 4b1c5e2aa062a37052675263e7a742bdb760eed9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 15:24:46 +0200 Subject: [PATCH 036/155] Fix feature flag E2E test to use Account flags after rename --- .../tests/e2e/feature-flag-flows.spec.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 144f32c68..faf9a44ca 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -11,9 +11,9 @@ test.describe("@smoke", () => { * FEATURE FLAG SYSTEM E2E TEST * * Tests the full feature flag management flow: - * - Back-office flag list: view flags grouped by scope (Tenant, User, System) - * - Back-office flag detail: navigate into tenant-scoped flag, toggle tenant override twice, set A/B rollout percentage - * - Account settings: verify Features section, toggle tenant-scoped custom branding flag + * - Back-office flag list: view flags grouped by scope (Account, User, System) + * - Back-office flag detail: navigate into account-scoped flag, toggle account override twice, set A/B rollout percentage + * - Account settings: verify Features section, toggle account-scoped custom branding flag * - User preferences: verify Beta features section, toggle user-scoped compact view flag */ test("should manage feature flags across back-office, account settings & user preferences", async ({ @@ -40,10 +40,10 @@ test.describe("@smoke", () => { await step("Load feature flags page & verify flags grouped by scope")(async () => { await expect(page.getByRole("heading", { name: "Feature flags" })).toBeVisible(); - const tenantTable = page.getByRole("table", { name: "Account flags" }); - await expect(tenantTable.getByText("Beta features")).toBeVisible(); - await expect(tenantTable.getByText("Single sign-on")).toBeVisible(); - await expect(tenantTable.getByText("Custom branding")).toBeVisible(); + const accountTable = page.getByRole("table", { name: "Account flags" }); + await expect(accountTable.getByText("Beta features")).toBeVisible(); + await expect(accountTable.getByText("Single sign-on")).toBeVisible(); + await expect(accountTable.getByText("Custom branding")).toBeVisible(); const userTable = page.getByRole("table", { name: "User flags" }); await expect(userTable.getByText("Compact view")).toBeVisible(); @@ -56,28 +56,28 @@ test.describe("@smoke", () => { // === BACK-OFFICE FLAG DETAIL === await step("Click into beta-features flag detail & verify detail page loads")(async () => { - const tenantTable = page.getByRole("table", { name: "Account flags" }); - const betaRow = tenantTable.locator("tr").filter({ hasText: "Beta features" }); + const accountTable = page.getByRole("table", { name: "Account flags" }); + const betaRow = accountTable.locator("tr").filter({ hasText: "Beta features" }); await betaRow.click(); await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); await expect(page.getByRole("heading", { name: "Account status" })).toBeVisible(); })(); - await step("Search for a tenant & verify search results table appears")(async () => { + await step("Search for an account & verify search results table appears")(async () => { await page.getByPlaceholder("Search by account name or ID").fill("test"); await expect(page.getByRole("table", { name: "Search results" })).toBeVisible(); })(); - await step("Toggle tenant override & verify toast confirms state change")(async () => { + await step("Toggle account override & verify toast confirms state change")(async () => { const overrideSwitch = page.getByRole("table", { name: "Search results" }).getByRole("switch").first(); await overrideSwitch.click(); await expectToastMessage(context, "Beta features"); })(); - await step("Toggle tenant override back & verify toast confirms state change")(async () => { + await step("Toggle account override back & verify toast confirms state change")(async () => { const overrideSwitch = page.getByRole("table", { name: "Search results" }).getByRole("switch").first(); await overrideSwitch.click(); @@ -118,9 +118,9 @@ test.describe("@smoke", () => { await backOfficeContext.close(); - // === ACCOUNT SETTINGS: TENANT FEATURE FLAGS === + // === ACCOUNT SETTINGS: ACCOUNT FEATURE FLAGS === - await step("Navigate to account settings & verify Features section with tenant flags")(async () => { + await step("Navigate to account settings & verify Features section with account flags")(async () => { await ownerPage.goto("/account/settings"); await expect(ownerPage.getByRole("heading", { name: "Features" })).toBeVisible(); From 5e11f2ac74c7bf4665a3b736b326a1891148fa85 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 16:06:34 +0200 Subject: [PATCH 037/155] Fix feature flag E2E test toast assertions to match current messages --- .../account/WebApp/tests/e2e/feature-flag-flows.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index faf9a44ca..d91f2b94c 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -86,8 +86,7 @@ test.describe("@smoke", () => { await step("Set A/B rollout percentage & verify success toast on blur")(async () => { const percentageInput = page.getByRole("spinbutton", { name: "Rollout %" }); - const newValue = String(10 + (Date.now() % 80)); - await percentageInput.fill(newValue); + await percentageInput.fill(String((Date.now() % 99) + 1)); await blurActiveElement(page); await expectToastMessage(context, "Rollout percentage updated"); From 30f3d6b78fe49f31964012a200523f7f199ec5d0 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 18:00:35 +0200 Subject: [PATCH 038/155] Fix flaky tenant-switching test by handling non-deterministic invitation order --- .../tests/e2e/tenant-switching-flows.spec.ts | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/application/account/WebApp/tests/e2e/tenant-switching-flows.spec.ts b/application/account/WebApp/tests/e2e/tenant-switching-flows.spec.ts index 01571c08a..0374cdc72 100644 --- a/application/account/WebApp/tests/e2e/tenant-switching-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/tenant-switching-flows.spec.ts @@ -45,6 +45,10 @@ test.describe("@comprehensive", () => { const secondaryTenantName = `Secondary-${timestamp}`; const tertiaryTenantName = `Tertiary-${timestamp}`; + // The last accepted invitation tenant (set after both invitations are accepted) + // Invitation order is non-deterministic, so we capture which tenant was accepted last + let lastAcceptedTenantName = ""; + // === SINGLE TENANT DISPLAY === await step("Create single tenant & verify dropdown is hidden")(async () => { await completeSignupFlow(page1, expect, user, testContext1); @@ -187,12 +191,12 @@ test.describe("@comprehensive", () => { await expect(accountMenuButton).toBeVisible(); })(); - // === TERTIARY TENANT INVITATION ACCEPTANCE === - await step("Accept invitation for tertiary tenant & verify successful tenant switch")(async () => { + // === REMAINING INVITATION ACCEPTANCE === + await step("Accept remaining invitation & verify successful tenant switch")(async () => { // Remaining pending invitation shows in banner await expect(page1.getByText("You have been invited to join")).toBeVisible(); - // Click View invitation to accept the tertiary tenant invitation + // Click View invitation to accept the remaining invitation await page1.getByRole("button", { name: "View invitation" }).click(); // Accept invitation dialog should appear for this tenant @@ -205,9 +209,11 @@ test.describe("@comprehensive", () => { // Wait for navigation to complete after accepting invitation await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); - // Account menu now shows the tertiary tenant name + // Capture which tenant the user ended up on (invitation order is non-deterministic) const accountMenuButton = page1.getByRole("button", { name: "User menu" }); - await expect(accountMenuButton).toContainText(tertiaryTenantName); + const menuText = await accountMenuButton.textContent(); + lastAcceptedTenantName = [secondaryTenantName, tertiaryTenantName].find((name) => menuText?.includes(name)) ?? ""; + expect(lastAcceptedTenantName).not.toBe(""); })(); // === OPEN SECOND TAB === @@ -220,19 +226,19 @@ test.describe("@comprehensive", () => { await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); await expect(page2.locator('nav[aria-label="Main navigation"]')).toBeVisible(); - // Both tabs should show the same tenant (tertiary) - await expect(page1.locator('nav[aria-label="Main navigation"]')).toContainText(tertiaryTenantName); - await expect(page2.locator('nav[aria-label="Main navigation"]')).toContainText(tertiaryTenantName); + // Both tabs should show the same tenant (last accepted) + await expect(page1.locator('nav[aria-label="Main navigation"]')).toContainText(lastAcceptedTenantName); + await expect(page2.locator('nav[aria-label="Main navigation"]')).toContainText(lastAcceptedTenantName); })(); // === TENANT PREFERENCE PERSISTENCE === await step("Logout and login again & verify tenant preference persists")(async () => { // Reuse the existing tenant button reference const navElement = page1.locator("nav").first(); - const tenantButton = navElement.locator("button").filter({ hasText: tertiaryTenantName }); + const tenantButton = navElement.locator("button").filter({ hasText: lastAcceptedTenantName }); - // Should still be on tertiary tenant - await expect(tenantButton).toContainText(tertiaryTenantName); + // Should still be on last accepted tenant + await expect(tenantButton).toContainText(lastAcceptedTenantName); // Logout testContext1.monitoring.expectedStatusCodes.push(401); @@ -260,30 +266,30 @@ test.describe("@comprehensive", () => { // Wait for navigation to complete - no auth sync dialog since it's the same user await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); - // Should login to tertiary tenant (last selected) + // Should login to last accepted tenant (last selected) const navElementAfter = page1.locator("nav").first(); - const tenantButtonAfter = navElementAfter.locator("button").filter({ hasText: tertiaryTenantName }); - await expect(tenantButtonAfter).toContainText(tertiaryTenantName); + const tenantButtonAfter = navElementAfter.locator("button").filter({ hasText: lastAcceptedTenantName }); + await expect(tenantButtonAfter).toContainText(lastAcceptedTenantName); - // page2 also shows tertiary tenant when navigated + // page2 also shows last accepted tenant when navigated await page2.goto("/account"); await expect(page2.locator('nav[aria-label="Main navigation"]')).toBeVisible(); - await expect(page2.locator('nav[aria-label="Main navigation"]')).toContainText(tertiaryTenantName); + await expect(page2.locator('nav[aria-label="Main navigation"]')).toContainText(lastAcceptedTenantName); })(); // === TENANT CONTEXT ACROSS NAVIGATION === await step("Navigate across pages & verify tenant context remains consistent")(async () => { const accountMenuButton = page1.getByRole("button", { name: "User menu" }); - // We're on tertiary tenant at this point - await expect(accountMenuButton).toContainText(tertiaryTenantName); + // We're on last accepted tenant at this point + await expect(accountMenuButton).toContainText(lastAcceptedTenantName); // Navigate to Users page await page1.goto("/account/users"); await expect(page1.getByRole("heading", { name: "Users" })).toBeVisible(); // Tenant should still be visible - await expect(accountMenuButton).toContainText(tertiaryTenantName); + await expect(accountMenuButton).toContainText(lastAcceptedTenantName); // Navigate to Account settings page await page1.goto("/account/settings"); @@ -291,7 +297,7 @@ test.describe("@comprehensive", () => { // Should show correct tenant name in account settings const accountNameInput = page1.getByRole("textbox", { name: "Account name" }); - await expect(accountNameInput).toHaveValue(tertiaryTenantName); + await expect(accountNameInput).toHaveValue(lastAcceptedTenantName); // Switch to primary tenant via Account menu > Switch account submenu await accountMenuButton.dispatchEvent("click"); From 4d8f21dbcf59a6ab5620a8ca5497c02e71e64aa8 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 21:40:12 +0200 Subject: [PATCH 039/155] Replace hash-based rollout buckets with van der Corput sequence --- .../-components/FlagInfoSection.tsx | 4 +- .../-components/TenantOverridesSection.tsx | 4 +- .../-components/UserOverridesSection.tsx | 4 +- .../-components/rolloutBucket.ts | 22 ++--- .../shared/translations/locale/da-DK.po | 10 ++- ...0_ReplaceRolloutBucketsWithVanDerCorput.cs | 87 +++++++++++++++++++ .../SetFeatureFlagRolloutPercentage.cs | 23 +++-- .../FeatureFlags/Domain/FeatureFlag.cs | 8 +- .../Features/Tenants/Commands/CreateTenant.cs | 3 +- .../Core/Features/Tenants/Domain/Tenant.cs | 11 ++- .../Tenants/Domain/TenantRepository.cs | 16 ++++ .../Features/Users/Commands/CreateUser.cs | 3 +- .../Core/Features/Users/Domain/User.cs | 11 ++- .../Features/Users/Domain/UserRepository.cs | 16 ++++ .../Authentication/GetUserSessionsTests.cs | 4 +- .../Tests/Authentication/SwitchTenantTests.cs | 20 +++-- .../GetDashboardMrrConsistencySummaryTests.cs | 1 + .../GetUnsyncedSubscriptionsSummaryTests.cs | 1 + .../Dashboard/GetDashboardKpisTests.cs | 4 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 1 + .../GetDashboardPlanDistributionTests.cs | 1 + .../GetDashboardRecentSignupsTests.cs | 1 + .../GetDashboardRecentStripeEventsTests.cs | 1 + .../Dashboard/GetDashboardTrendsTests.cs | 4 +- .../GetBackOfficeBillingEventsTests.cs | 1 + application/account/Tests/DatabaseSeeder.cs | 6 +- .../CompleteEmailLoginTests.cs | 5 +- .../ResendEmailLoginCodeTests.cs | 3 +- .../StartEmailLoginTests.cs | 6 +- .../CompleteExternalLoginTests.cs | 17 ++-- .../Domain/ExternalLoginTests.cs | 8 +- .../ExternalAuthenticationTestBase.cs | 3 +- .../Tests/FeatureFlags/FeatureFlagTests.cs | 55 ++++++------ .../BackOffice/GetTenantDetailTests.cs | 3 + .../BackOffice/GetTenantUserCountsTests.cs | 3 +- .../Tenants/BackOffice/GetTenantUsersTests.cs | 4 +- .../Tenants/BackOffice/GetTenantsTests.cs | 1 + .../Tests/Tenants/GetTenantsForUserTests.cs | 16 +++- .../GetBackOfficeUserDetailTests.cs | 4 +- .../GetBackOfficeUserSessionsTests.cs | 4 +- .../BackOffice/GetBackOfficeUsersTests.cs | 4 +- .../Tests/Users/BulkDeleteUsersTests.cs | 9 +- .../Tests/Users/BulkPurgeUsersTests.cs | 12 ++- .../Tests/Users/DeclineInvitationTests.cs | 12 ++- .../account/Tests/Users/DeleteUserTests.cs | 9 +- .../Tests/Users/EmptyRecycleBinTests.cs | 6 +- .../Tests/Users/GetDeletedUsersTests.cs | 3 +- .../account/Tests/Users/GetUserByIdTests.cs | 3 +- .../Tests/Users/GetUserSummaryTests.cs | 9 +- .../account/Tests/Users/GetUsersTests.cs | 6 +- .../account/Tests/Users/InviteUserTests.cs | 3 +- .../account/Tests/Users/PurgeUserTests.cs | 6 +- .../account/Tests/Users/RestoreUserTests.cs | 6 +- .../FeatureFlags/RolloutBucketHasher.cs | 41 ++++----- 54 files changed, 374 insertions(+), 154 deletions(-) create mode 100644 application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs diff --git a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx index 6a8fb5dd9..728442aa0 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx @@ -81,8 +81,8 @@ function FlagMetadata({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { - Each account or user is assigned a fixed bucket (1-100) based on their ID. The rollout targets a - specific range of buckets, ensuring consistent and predictable feature rollout. + Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout + targets a specific range of buckets, ensuring consistent and predictable feature rollout. diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index da33dfa0d..ac4ec39f5 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -42,7 +42,7 @@ export function TenantOverridesSection({ sortBySourceThenBucket( filtered.filter((t) => t.isEnabled), (t) => t.source, - (t) => t.tenantId, + (t) => t.rolloutBucket, "enabled", bucketRange ), @@ -54,7 +54,7 @@ export function TenantOverridesSection({ sortBySourceThenBucket( filtered.filter((t) => !t.isEnabled), (t) => t.source, - (t) => t.tenantId, + (t) => t.rolloutBucket, "disabled", bucketRange ), diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 5fb1517f3..c24b4d8f6 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -56,14 +56,14 @@ export function UserOverridesSection({ enabledUsers: sortBySourceThenBucket( enabled, (u) => u.source, - (u) => u.userId, + (u) => u.rolloutBucket, "enabled", bucketRange ), disabledUsers: sortBySourceThenBucket( disabled, (u) => u.source, - (u) => u.userId, + (u) => u.rolloutBucket, "disabled", bucketRange ) diff --git a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts index dbce228bf..3b43701b5 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts @@ -1,23 +1,12 @@ import { t } from "@lingui/core/macro"; -const FNV_OFFSET_BASIS = 2166136261; -const FNV_PRIME = 16777619; const BUCKET_MAX = 100; -export function computeBucket(entityId: string): number { - let hash = FNV_OFFSET_BASIS; - for (let i = 0; i < entityId.length; i++) { - hash ^= entityId.charCodeAt(i); - hash = Math.imul(hash, FNV_PRIME) >>> 0; - } - return (hash % 99) + 1; -} - export function formatBucketRange(bucketStart: number, bucketEnd: number, rolloutPercentage: number): string { if (bucketStart <= bucketEnd) { return t`Rollout buckets: ${bucketStart}-${bucketEnd} (${rolloutPercentage}%)`; } - return t`Rollout buckets: ${bucketStart}-100 and 1-${bucketEnd} (${rolloutPercentage}%)`; + return t`Rollout buckets: ${bucketStart}-99 and 0-${bucketEnd} (${rolloutPercentage}%)`; } export interface BucketRange { @@ -25,8 +14,7 @@ export interface BucketRange { bucketEnd: number; } -function bucketSortOrder(entityId: string, group: "enabled" | "disabled", range: BucketRange): number { - const bucket = computeBucket(entityId); +function bucketSortOrder(bucket: number, group: "enabled" | "disabled", range: BucketRange): number { const ref = group === "disabled" ? range.bucketEnd : range.bucketStart; return (bucket - ref + BUCKET_MAX) % BUCKET_MAX; } @@ -34,15 +22,15 @@ function bucketSortOrder(entityId: string, group: "enabled" | "disabled", range: export function sortBySourceThenBucket( items: T[], getSource: (item: T) => string, - getEntityId: (item: T) => string, + getBucket: (item: T) => number, group: "enabled" | "disabled", bucketRange: BucketRange | null ): T[] { return [...items].sort((a, b) => { const aOrder = - getSource(a) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getEntityId(a), group, bucketRange) : 0; + getSource(a) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getBucket(a), group, bucketRange) : 0; const bOrder = - getSource(b) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getEntityId(b), group, bucketRange) : 0; + getSource(b) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getBucket(b), group, bucketRange) : 0; return aOrder - bOrder; }); } diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 0873f3d6f..34bf08fed 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -339,6 +339,12 @@ msgstr "Nedgraderer" msgid "Drift detected" msgstr "Afvigelser fundet" +msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." +msgstr "Hver konto eller bruger tildeles en fast bucket (0-99) baseret på deres sekvensnummer. Udrulningen rammer et bestemt bucket-interval, så funktionsudrulningen bliver konsistent og forudsigelig." + +msgid "Early access to experimental features before general availability" +msgstr "Tidlig adgang til eksperimentelle funktioner før generel tilgængelighed" + msgid "Email" msgstr "E-mail" @@ -876,8 +882,8 @@ msgstr "Udrulnings-%" msgid "Rollout buckets: {bucketStart}-{bucketEnd} ({rolloutPercentage}%)" msgstr "Udrulningsbuckets: {bucketStart}-{bucketEnd} ({rolloutPercentage}%)" -msgid "Rollout buckets: {bucketStart}-100 and 1-{bucketEnd} ({rolloutPercentage}%)" -msgstr "Udrulningsbuckets: {bucketStart}-100 og 1-{bucketEnd} ({rolloutPercentage}%)" +msgid "Rollout buckets: {bucketStart}-99 and 0-{bucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsbuckets: {bucketStart}-99 og 0-{bucketEnd} ({rolloutPercentage}%)" msgid "Rollout percentage" msgstr "Udrulningsprocent" diff --git a/application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs b/application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs new file mode 100644 index 000000000..174bba5e5 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260404000000_ReplaceRolloutBucketsWithVanDerCorput")] +public sealed class ReplaceRolloutBucketsWithVanDerCorput : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // Create a temporary function to compute van der Corput buckets in SQL + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION van_der_corput_bucket(seq integer) RETURNS integer AS $$ + DECLARE + result double precision := 0; + denominator double precision := 2; + n integer := seq; + BEGIN + WHILE n > 0 LOOP + result := result + (n & 1)::double precision / denominator; + n := n >> 1; + denominator := denominator * 2; + END LOOP; + RETURN floor(result * 100)::integer; + END; + $$ LANGUAGE plpgsql IMMUTABLE; + """ + ); + + // Add rollout_bucket_sequence to tenants + migrationBuilder.AddColumn("rollout_bucket_sequence", "tenants", "integer", nullable: true); + + migrationBuilder.Sql( + """ + WITH numbered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) - 1 AS seq + FROM tenants + ) + UPDATE tenants SET rollout_bucket_sequence = numbered.seq + FROM numbered WHERE tenants.id = numbered.id + """ + ); + + migrationBuilder.Sql("ALTER TABLE tenants ALTER COLUMN rollout_bucket_sequence SET NOT NULL"); + + // Recompute rollout_bucket using van der Corput + migrationBuilder.Sql("UPDATE tenants SET rollout_bucket = van_der_corput_bucket(rollout_bucket_sequence)"); + + // Add rollout_bucket_sequence to users + migrationBuilder.AddColumn("rollout_bucket_sequence", "users", "integer", nullable: true); + + migrationBuilder.Sql( + """ + WITH numbered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) - 1 AS seq + FROM users + ) + UPDATE users SET rollout_bucket_sequence = numbered.seq + FROM numbered WHERE users.id = numbered.id + """ + ); + + migrationBuilder.Sql("ALTER TABLE users ALTER COLUMN rollout_bucket_sequence SET NOT NULL"); + + // Recompute rollout_bucket using van der Corput + migrationBuilder.Sql("UPDATE users SET rollout_bucket = van_der_corput_bucket(rollout_bucket_sequence)"); + + // Drop the temporary function + migrationBuilder.Sql("DROP FUNCTION van_der_corput_bucket(integer)"); + + // Update bucket_range constraint from 1-100 to 0-99 + migrationBuilder.Sql("ALTER TABLE feature_flags DROP CONSTRAINT ck_feature_flags_bucket_range"); + + migrationBuilder.Sql( + """ + ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_bucket_range + CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 0 AND 99 AND bucket_end BETWEEN 0 AND 99))) + """ + ); + + // Update any existing rollout ranges that used bucket 100 to use 99 + migrationBuilder.Sql("UPDATE feature_flags SET bucket_end = 99 WHERE bucket_end = 100"); + migrationBuilder.Sql("UPDATE feature_flags SET bucket_start = 99 WHERE bucket_start = 100"); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs index 1f5c8effb..0bbf9d3a0 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs @@ -3,7 +3,6 @@ using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; -using SharedKernel.FeatureFlags; using SharedKernel.Telemetry; namespace Account.Features.FeatureFlags.Commands; @@ -50,13 +49,12 @@ public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, else if (command.RolloutPercentage == 100) { bucketStart = 0; - bucketEnd = 100; + bucketEnd = 99; } else { - // Normal rollout uses buckets 1-99. Bucket 0 (always opt-in) and 100 (always opt-out) are reserved. - bucketStart = RolloutBucketHasher.ComputeBucket(command.FlagKey); - bucketEnd = (bucketStart - 1 + command.RolloutPercentage - 1) % 99 + 1; + bucketStart = ComputeStartingBucket(command.FlagKey); + bucketEnd = (bucketStart.Value + command.RolloutPercentage - 1) % 100; } flag.SetRolloutRange(bucketStart, bucketEnd); @@ -68,4 +66,19 @@ public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, return Result.Success(); } + + private static int ComputeStartingBucket(string flagKey) + { + unchecked + { + var hash = 2166136261u; + foreach (var c in flagKey) + { + hash ^= c; + hash *= 16777619u; + } + + return (int)(hash % 100); + } + } } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index 588cd2b31..dca6be79e 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -74,14 +74,14 @@ public void SetRolloutRange(int? bucketStart, int? bucketEnd) throw new ArgumentException("Bucket start and bucket end must both be set or both be null."); } - if (bucketStart is not null && (bucketStart < 0 || bucketStart > 100)) + if (bucketStart is not null && (bucketStart < 0 || bucketStart > 99)) { - throw new ArgumentOutOfRangeException(nameof(bucketStart), "Bucket start must be between 0 and 100."); + throw new ArgumentOutOfRangeException(nameof(bucketStart), "Bucket start must be between 0 and 99."); } - if (bucketEnd is not null && (bucketEnd < 0 || bucketEnd > 100)) + if (bucketEnd is not null && (bucketEnd < 0 || bucketEnd > 99)) { - throw new ArgumentOutOfRangeException(nameof(bucketEnd), "Bucket end must be between 0 and 100."); + throw new ArgumentOutOfRangeException(nameof(bucketEnd), "Bucket end must be between 0 and 99."); } BucketStart = bucketStart; diff --git a/application/account/Core/Features/Tenants/Commands/CreateTenant.cs b/application/account/Core/Features/Tenants/Commands/CreateTenant.cs index d2885a4f3..a37bc3024 100644 --- a/application/account/Core/Features/Tenants/Commands/CreateTenant.cs +++ b/application/account/Core/Features/Tenants/Commands/CreateTenant.cs @@ -18,7 +18,8 @@ internal sealed class CreateTenantHandler(ITenantRepository tenantRepository, IS { public async Task> Handle(CreateTenantCommand command, CancellationToken cancellationToken) { - var tenant = Tenant.Create(command.OwnerEmail); + var rolloutBucketSequence = await tenantRepository.GetNextRolloutBucketSequenceUnfilteredAsync(cancellationToken); + var tenant = Tenant.Create(command.OwnerEmail, rolloutBucketSequence); await tenantRepository.AddAsync(tenant, cancellationToken); var subscription = Subscription.Create(tenant.Id); diff --git a/application/account/Core/Features/Tenants/Domain/Tenant.cs b/application/account/Core/Features/Tenants/Domain/Tenant.cs index f93856393..fc6391d8d 100644 --- a/application/account/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account/Core/Features/Tenants/Domain/Tenant.cs @@ -6,12 +6,13 @@ namespace Account.Features.Tenants.Domain; public sealed class Tenant : SoftDeletableAggregateRoot { - private Tenant() : base(TenantId.NewId()) + private Tenant(int rolloutBucketSequence) : base(TenantId.NewId()) { State = TenantState.Active; Plan = SubscriptionPlan.Basis; Logo = new Logo(); - RolloutBucket = RolloutBucketHasher.ComputeBucket(Id.Value.ToString()); + RolloutBucketSequence = rolloutBucketSequence; + RolloutBucket = RolloutBucketHasher.ComputeBucket(rolloutBucketSequence); } public string Name { get; private set; } = string.Empty; @@ -26,13 +27,15 @@ private Tenant() : base(TenantId.NewId()) public Logo Logo { get; private set; } + public int RolloutBucketSequence { get; private set; } + public int RolloutBucket { get; private set; } public int FeatureFlagVersion { get; private set; } - public static Tenant Create(string email) + public static Tenant Create(string email, int rolloutBucketSequence) { - var tenant = new Tenant(); + var tenant = new Tenant(rolloutBucketSequence); tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, email)); return tenant; } diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 90f65c857..92153cf09 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -56,6 +56,12 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken); + /// + /// Retrieves the next rollout bucket sequence number across all tenants without applying query filters. + /// This method must query across all tenants to ensure globally unique sequence numbers. + /// + Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken); + Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken); Task IncrementAllFeatureFlagVersionsAsync(CancellationToken cancellationToken); @@ -162,6 +168,16 @@ public async Task GetMostRecentSignupsUnfilteredAsync(int limit, Cance return tenants.OrderByDescending(t => t.CreatedAt).Take(limit).ToArray(); } + /// + /// Retrieves the next rollout bucket sequence number across all tenants without applying query filters. + /// This method must query across all tenants to ensure globally unique sequence numbers. + /// + public async Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken) + { + var maxSequence = await DbSet.IgnoreQueryFilters().MaxAsync(t => (int?)t.RolloutBucketSequence, cancellationToken); + return (maxSequence ?? -1) + 1; + } + public async Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken) { return await DbSet.Where(t => t.Id == tenantId).Select(t => t.FeatureFlagVersion).SingleOrDefaultAsync(cancellationToken); diff --git a/application/account/Core/Features/Users/Commands/CreateUser.cs b/application/account/Core/Features/Users/Commands/CreateUser.cs index d8c61b3b7..d060d9866 100644 --- a/application/account/Core/Features/Users/Commands/CreateUser.cs +++ b/application/account/Core/Features/Users/Commands/CreateUser.cs @@ -48,7 +48,8 @@ public async Task> Handle(CreateUserCommand command, Cancellation var locale = SinglePageAppConfiguration.SupportedLocalizations.Contains(command.PreferredLocale) ? command.PreferredLocale : string.Empty; - var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale); + var rolloutBucketSequence = await userRepository.GetNextRolloutBucketSequenceUnfilteredAsync(cancellationToken); + var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale, rolloutBucketSequence); await userRepository.AddAsync(user, cancellationToken); var gravatar = await gravatarClient.GetGravatar(user.Id, user.Email, cancellationToken); diff --git a/application/account/Core/Features/Users/Domain/User.cs b/application/account/Core/Features/Users/Domain/User.cs index ff1834a58..fde145c20 100644 --- a/application/account/Core/Features/Users/Domain/User.cs +++ b/application/account/Core/Features/Users/Domain/User.cs @@ -8,7 +8,7 @@ namespace Account.Features.Users.Domain; public sealed class User : SoftDeletableAggregateRoot, ITenantScopedEntity { - private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) + private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int rolloutBucketSequence) : base(UserId.NewId()) { Email = email; @@ -18,7 +18,8 @@ private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed Locale = locale ?? string.Empty; Avatar = new Avatar(); ExternalIdentities = []; - RolloutBucket = RolloutBucketHasher.ComputeBucket(Id.Value); + RolloutBucketSequence = rolloutBucketSequence; + RolloutBucket = RolloutBucketHasher.ComputeBucket(rolloutBucketSequence); } public string Email @@ -47,13 +48,15 @@ public string Email public ImmutableArray ExternalIdentities { get; private set; } + public int RolloutBucketSequence { get; private set; } + public int RolloutBucket { get; private set; } public TenantId TenantId { get; } - public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) + public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int rolloutBucketSequence) { - return new User(tenantId, email, role, emailConfirmed, locale); + return new User(tenantId, email, role, emailConfirmed, locale, rolloutBucketSequence); } public void Update(string firstName, string lastName, string title) diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 7cc2ea9e3..0eef84632 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -103,6 +103,12 @@ CancellationToken cancellationToken /// Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); + /// + /// Retrieves the next rollout bucket sequence number across all users without applying query filters. + /// This method must query across all users to ensure globally unique sequence numbers. + /// + Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken); + /// /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. @@ -552,6 +558,16 @@ public async Task GetAllUnfilteredAsync(CancellationToken cancellationTo .ToArrayAsync(cancellationToken); } + /// + /// Retrieves the next rollout bucket sequence number across all users without applying query filters. + /// This method must query across all users to ensure globally unique sequence numbers. + /// + public async Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken) + { + var maxSequence = await DbSet.IgnoreQueryFilters().MaxAsync(u => (int?)u.RolloutBucketSequence, cancellationToken); + return (maxSequence ?? -1) + 1; + } + /// /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. diff --git a/application/account/Tests/Authentication/GetUserSessionsTests.cs b/application/account/Tests/Authentication/GetUserSessionsTests.cs index 916d3cdd1..e1491bf9d 100644 --- a/application/account/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account/Tests/Authentication/GetUserSessionsTests.cs @@ -138,6 +138,7 @@ private long InsertTenant(string name) ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -163,7 +164,8 @@ private void InsertUser(long tenantId, UserId userId, string email) ("role", "Owner"), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index 1ee1b21d2..35bd2894e 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -33,6 +33,7 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -53,7 +54,8 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -117,6 +119,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -135,7 +138,8 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -181,6 +185,7 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -201,7 +206,8 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -255,6 +261,7 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -276,7 +283,8 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -337,6 +345,7 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -357,7 +366,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs index db17bb011..c0a93e505 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs @@ -117,6 +117,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs index 01590d91d..2d1c10113 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs @@ -67,6 +67,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index 43511e5c1..4eaaeee2b 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -267,6 +267,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, DateTimeOffset c ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -395,7 +396,8 @@ private UserId SeedUser(TenantId tenantId, string email, DateTimeOffset createdA ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); return userId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 40aacd324..1f9bfa816 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -116,6 +116,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs index 3516cc353..95fa75aec 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs @@ -91,6 +91,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan) ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs index 4eaf136dd..38fda399c 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs @@ -84,6 +84,7 @@ private void SeedTenant(string name, DateTimeOffset createdAt) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs index c7b0395a1..4ddc5d5b8 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs @@ -112,6 +112,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs index 92d6e4835..e045ec4a7 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs @@ -192,6 +192,7 @@ private TenantId SeedTenant(string name, DateTimeOffset createdAt) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -214,7 +215,8 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset createdAt) ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs index 7ea11ab5c..ece45958a 100644 --- a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs +++ b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs @@ -253,6 +253,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/DatabaseSeeder.cs b/application/account/Tests/DatabaseSeeder.cs index 4e16efe45..27a2cb658 100644 --- a/application/account/Tests/DatabaseSeeder.cs +++ b/application/account/Tests/DatabaseSeeder.cs @@ -24,13 +24,13 @@ public sealed class DatabaseSeeder public DatabaseSeeder(AccountDbContext accountDbContext) { - Tenant1 = Tenant.Create("owner@tenant-1.com"); + Tenant1 = Tenant.Create("owner@tenant-1.com", 0); accountDbContext.Set().AddRange(Tenant1); - Tenant1Owner = User.Create(Tenant1.Id, "owner@tenant-1.com", UserRole.Owner, true, null); + Tenant1Owner = User.Create(Tenant1.Id, "owner@tenant-1.com", UserRole.Owner, true, null, 0); accountDbContext.Set().AddRange(Tenant1Owner); - Tenant1Member = User.Create(Tenant1.Id, "member1@tenant-1.com", UserRole.Member, true, null); + Tenant1Member = User.Create(Tenant1.Id, "member1@tenant-1.com", UserRole.Member, true, null, 1); accountDbContext.Set().AddRange(Tenant1Member); Tenant1OwnerSession = Session.Create(Tenant1.Id, Tenant1Owner.Id, LoginMethod.OneTimePassword, "TestUserAgent", IPAddress.Loopback); diff --git a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index 0db2344ab..cb173b558 100644 --- a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -221,6 +221,7 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -264,7 +265,8 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -326,6 +328,7 @@ public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_Sh ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs b/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs index 00fba3b73..47139b091 100644 --- a/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs +++ b/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs @@ -76,7 +76,8 @@ public async Task ResendEmailLoginCode_WhenUserHasDanishLocale_ShouldSendDanishR ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "da-DK"), ("external_identities", "[]"), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); var emailLoginId = await StartEmailLogin(email); diff --git a/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs index c1209fc09..57ad2ec74 100644 --- a/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs @@ -75,7 +75,8 @@ public async Task StartEmailLogin_WhenUserHasDanishLocale_ShouldSendDanishLoginE ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "da-DK"), ("external_identities", "[]"), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); var command = new StartEmailLoginCommand(email); @@ -229,7 +230,8 @@ public async Task StartEmailLogin_WhenUserIsSoftDeleted_ShouldReturnFakeEmailLog ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs index 1e84414fe..27f98165e 100644 --- a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -218,7 +218,8 @@ public async Task CompleteExternalLogin_WhenUserHasNoExternalIdentity_ShouldLink ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -260,7 +261,8 @@ public async Task CompleteExternalLogin_WhenInvitedUserHasNoName_ShouldUpdateNam ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -303,7 +305,8 @@ public async Task CompleteExternalLogin_WhenUserAlreadyHasName_ShouldNotOverwrit ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -413,6 +416,7 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -457,7 +461,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", identities), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -475,7 +480,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", identities), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -598,6 +604,7 @@ public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs index 7ee4b9727..6e46532fe 100644 --- a/application/account/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs @@ -197,7 +197,7 @@ public void IsExpired_WhenNowIsBeforeCreatedAt_ShouldThrowSecurityException() public void AddExternalIdentity_WhenNewProvider_ShouldAddIdentity() { // Arrange - var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US", 0); // Act user.AddExternalIdentity(ExternalProviderType.Google, "google-user-id-123"); @@ -212,7 +212,7 @@ public void AddExternalIdentity_WhenNewProvider_ShouldAddIdentity() public void AddExternalIdentity_WhenDuplicateProvider_ShouldThrowUnreachableException() { // Arrange - var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US", 0); user.AddExternalIdentity(ExternalProviderType.Google, "google-user-id-123"); // Act @@ -227,7 +227,7 @@ public void AddExternalIdentity_WhenDuplicateProvider_ShouldThrowUnreachableExce public void GetExternalIdentity_WhenProviderExists_ShouldReturnIdentity() { // Arrange - var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US", 0); user.AddExternalIdentity(ExternalProviderType.Google, "google-user-id-123"); // Act @@ -243,7 +243,7 @@ public void GetExternalIdentity_WhenProviderExists_ShouldReturnIdentity() public void GetExternalIdentity_WhenProviderDoesNotExist_ShouldReturnNull() { // Arrange - var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US", 0); // Act var identity = user.GetExternalIdentity(ExternalProviderType.Google); diff --git a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs index 25a8b10ed..a086bd2b9 100644 --- a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs +++ b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs @@ -235,7 +235,8 @@ protected UserId InsertUserWithExternalIdentity(string email, ExternalProviderTy ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", identities), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); return userId; diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 14961d367..09a70039c 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -691,7 +691,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFu "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); bucketStart.Should().Be(0); - bucketEnd.Should().Be(100); + bucketEnd.Should().Be(99); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); @@ -795,7 +795,7 @@ public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource( ); Connection.Update("feature_flags", "id", baseRowId, [ ("bucket_start", 0), - ("bucket_end", 100) + ("bucket_end", 99) ] ); @@ -825,7 +825,7 @@ public async Task GetFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActi ); Connection.Update("feature_flags", "id", baseRowId, [ ("bucket_start", 0), - ("bucket_end", 100) + ("bucket_end", 99) ] ); @@ -936,7 +936,7 @@ public void BucketRange_WhenNormalRange_ShouldMatchCorrectly() [Fact] public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() { - // Arrange & Act & Assert (wrap-around within 1-99 range) + // Arrange & Act & Assert (wrap-around within 0-99 range) IsInBucketRange(95, 90, 10).Should().BeTrue(); IsInBucketRange(5, 90, 10).Should().BeTrue(); IsInBucketRange(50, 90, 10).Should().BeFalse(); @@ -944,41 +944,43 @@ public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() IsInBucketRange(10, 90, 10).Should().BeTrue(); IsInBucketRange(11, 90, 10).Should().BeFalse(); IsInBucketRange(89, 90, 10).Should().BeFalse(); - } - - [Fact] - public void BucketRange_WhenBucketZero_ShouldAlwaysBeIncluded() - { - // Bucket 0 = always opt-in, included in any rollout range - IsInBucketRange(0, 1, 50).Should().BeTrue(); - IsInBucketRange(0, 50, 99).Should().BeTrue(); IsInBucketRange(0, 90, 10).Should().BeTrue(); - IsInBucketRange(0, 0, 100).Should().BeTrue(); } [Fact] - public void BucketRange_WhenBucketHundred_ShouldOnlyBeIncludedAtFullRollout() + public void RolloutBucket_ShouldBeDeterministic() { - // Bucket 100 = always opt-out, only included when range covers all (0-100 = 100% rollout) - IsInBucketRange(100, 0, 100).Should().BeTrue(); - IsInBucketRange(100, 1, 99).Should().BeFalse(); - IsInBucketRange(100, 1, 50).Should().BeFalse(); - IsInBucketRange(100, 90, 10).Should().BeFalse(); + // Arrange + var sequenceNumber = 42; + + // Act + var bucket1 = RolloutBucketHasher.ComputeBucket(sequenceNumber); + var bucket2 = RolloutBucketHasher.ComputeBucket(sequenceNumber); + + // Assert + bucket1.Should().Be(bucket2); + bucket1.Should().BeInRange(0, 99); } [Fact] - public void RolloutBucket_ShouldBeDeterministic() + public void VanDerCorput_ShouldDistributeEvenly() { // Arrange - var entityId = "test-entity-123"; + var bucketCounts = new int[100]; // Act - var bucket1 = RolloutBucketHasher.ComputeBucket(entityId); - var bucket2 = RolloutBucketHasher.ComputeBucket(entityId); + for (var i = 0; i < 1000; i++) + { + var bucket = RolloutBucketHasher.ComputeBucket(i); + bucket.Should().BeInRange(0, 99); + bucketCounts[bucket]++; + } // Assert - bucket1.Should().Be(bucket2); - bucket1.Should().BeInRange(1, 99); + foreach (var count in bucketCounts) + { + count.Should().BeInRange(9, 11, "van der Corput should distribute within +/-1 of ideal"); + } } // JWT invalidation tests @@ -1126,12 +1128,11 @@ private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) private static int CountBucketsInRange(int bucketStart, int bucketEnd) { - // For normal rollout (1-99 range), count only the normal buckets if (bucketStart <= bucketEnd) { return bucketEnd - bucketStart + 1; } - return 99 - bucketStart + 1 + bucketEnd; + return 100 - bucketStart + bucketEnd + 1; } } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index ecaa32568..bafadd125 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -31,6 +31,7 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -104,6 +105,7 @@ public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscr ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":1}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -136,6 +138,7 @@ public async Task GetTenantDetail_WhenSubscriptionHasRefundedTransaction_ShouldE ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":1}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs index 85880d40c..483a52571 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs @@ -94,7 +94,8 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenA ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs index f6b7d99a4..4510e37c9 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs @@ -32,6 +32,7 @@ public async Task GetTenantUsers_WhenCalled_ShouldReturnUsersForThatTenantOnly() ("plan", "Basis"), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -155,7 +156,8 @@ private void SeedUser(TenantId tenantId, string email, string? firstName, string ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index 8495301a2..b6355f001 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -381,6 +381,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/GetTenantsForUserTests.cs b/application/account/Tests/Tenants/GetTenantsForUserTests.cs index 498ec1544..18c4fb22c 100644 --- a/application/account/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account/Tests/Tenants/GetTenantsForUserTests.cs @@ -33,6 +33,7 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -51,7 +52,8 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -111,6 +113,7 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -129,7 +132,8 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -160,6 +164,7 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -178,7 +183,8 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -210,6 +216,7 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -228,7 +235,8 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs index d9b8f645a..4eb1fa88f 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs @@ -128,6 +128,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -152,7 +153,8 @@ private UserId SeedUser(TenantId tenantId, string email, string? firstName, stri ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); return userId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs index 81966c546..c87997b10 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs @@ -127,6 +127,7 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -150,7 +151,8 @@ private UserId SeedUser(TenantId tenantId, string email, UserRole role) ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); return userId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index 3751fb904..c817fc7be 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -288,6 +288,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan = SubscriptionPla ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -346,7 +347,8 @@ private void SeedUser(TenantId tenantId, string email, string? firstName, string ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50) + ("rollout_bucket", 50), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/Users/BulkDeleteUsersTests.cs b/application/account/Tests/Users/BulkDeleteUsersTests.cs index 6de1e559d..a6ae0a70a 100644 --- a/application/account/Tests/Users/BulkDeleteUsersTests.cs +++ b/application/account/Tests/Users/BulkDeleteUsersTests.cs @@ -39,7 +39,8 @@ public async Task BulkDeleteUsers_WhenUsersExist_ShouldSoftDeleteUsers() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); } @@ -151,7 +152,8 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -170,7 +172,8 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/BulkPurgeUsersTests.cs b/application/account/Tests/Users/BulkPurgeUsersTests.cs index 5e48b1bd7..b76c928cd 100644 --- a/application/account/Tests/Users/BulkPurgeUsersTests.cs +++ b/application/account/Tests/Users/BulkPurgeUsersTests.cs @@ -36,7 +36,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); Connection.Insert("users", [ @@ -54,7 +55,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); Connection.Insert("users", [ @@ -72,7 +74,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -115,7 +118,8 @@ public async Task BulkPurgeUsers_WhenMember_ShouldReturnForbidden() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/DeclineInvitationTests.cs b/application/account/Tests/Users/DeclineInvitationTests.cs index 7a7ebca18..2b21cf848 100644 --- a/application/account/Tests/Users/DeclineInvitationTests.cs +++ b/application/account/Tests/Users/DeclineInvitationTests.cs @@ -32,6 +32,7 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -51,7 +52,8 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("role", nameof(UserRole.Member)), ("locale", ""), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -108,6 +110,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -121,6 +124,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -140,7 +144,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("role", nameof(UserRole.Member)), ("locale", ""), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -159,7 +164,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("role", nameof(UserRole.Member)), ("locale", ""), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/DeleteUserTests.cs b/application/account/Tests/Users/DeleteUserTests.cs index 1c28dd9ce..581bfd82a 100644 --- a/application/account/Tests/Users/DeleteUserTests.cs +++ b/application/account/Tests/Users/DeleteUserTests.cs @@ -46,7 +46,8 @@ public async Task DeleteUser_WhenUserExists_ShouldSoftDeleteUser() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -93,7 +94,8 @@ public async Task DeleteUser_WhenUserHasEmailLoginHistory_ShouldSoftDeleteUserAn ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -143,7 +145,8 @@ public async Task DeleteUser_WhenUserNeverConfirmedEmail_ShouldSoftDeleteUser() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/EmptyRecycleBinTests.cs b/application/account/Tests/Users/EmptyRecycleBinTests.cs index 781e31e89..77e40662e 100644 --- a/application/account/Tests/Users/EmptyRecycleBinTests.cs +++ b/application/account/Tests/Users/EmptyRecycleBinTests.cs @@ -34,7 +34,8 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); Connection.Insert("users", [ @@ -52,7 +53,8 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/GetDeletedUsersTests.cs b/application/account/Tests/Users/GetDeletedUsersTests.cs index 21e7f3ec2..4d3d7fa35 100644 --- a/application/account/Tests/Users/GetDeletedUsersTests.cs +++ b/application/account/Tests/Users/GetDeletedUsersTests.cs @@ -34,7 +34,8 @@ public async Task GetDeletedUsers_WhenOwner_ShouldReturnDeletedUsers() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/GetUserByIdTests.cs b/application/account/Tests/Users/GetUserByIdTests.cs index 0024c63c7..d55da8548 100644 --- a/application/account/Tests/Users/GetUserByIdTests.cs +++ b/application/account/Tests/Users/GetUserByIdTests.cs @@ -31,7 +31,8 @@ public GetUserByIdTests() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/Users/GetUserSummaryTests.cs b/application/account/Tests/Users/GetUserSummaryTests.cs index 042e92cc1..e4a67173a 100644 --- a/application/account/Tests/Users/GetUserSummaryTests.cs +++ b/application/account/Tests/Users/GetUserSummaryTests.cs @@ -38,7 +38,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -58,7 +59,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -78,7 +80,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/GetUsersTests.cs b/application/account/Tests/Users/GetUsersTests.cs index 0a0ac5609..3bf4d1b2e 100644 --- a/application/account/Tests/Users/GetUsersTests.cs +++ b/application/account/Tests/Users/GetUsersTests.cs @@ -33,7 +33,8 @@ public GetUsersTests() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); Connection.Insert("users", [ @@ -50,7 +51,8 @@ public GetUsersTests() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); } diff --git a/application/account/Tests/Users/InviteUserTests.cs b/application/account/Tests/Users/InviteUserTests.cs index 834eaf2b1..3700842d3 100644 --- a/application/account/Tests/Users/InviteUserTests.cs +++ b/application/account/Tests/Users/InviteUserTests.cs @@ -178,7 +178,8 @@ public async Task InviteUser_WhenDeletedUserExists_ShouldReturnBadRequest() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/PurgeUserTests.cs b/application/account/Tests/Users/PurgeUserTests.cs index 3761f7c23..f75cfae14 100644 --- a/application/account/Tests/Users/PurgeUserTests.cs +++ b/application/account/Tests/Users/PurgeUserTests.cs @@ -32,7 +32,8 @@ public async Task PurgeUser_WhenOwnerDeletesSoftDeletedUser_ShouldSucceed() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -94,7 +95,8 @@ public async Task PurgeUser_WhenUserNotDeleted_ShouldReturnNotFound() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/account/Tests/Users/RestoreUserTests.cs b/application/account/Tests/Users/RestoreUserTests.cs index af9485568..12d76a8b4 100644 --- a/application/account/Tests/Users/RestoreUserTests.cs +++ b/application/account/Tests/Users/RestoreUserTests.cs @@ -32,7 +32,8 @@ public async Task RestoreUser_WhenOwnerRestoresDeletedUser_ShouldSucceed() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); @@ -94,7 +95,8 @@ public async Task RestoreUser_WhenUserNotDeleted_ShouldReturnNotFound() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42) + ("rollout_bucket", 42), + ("rollout_bucket_sequence", 0) ] ); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs index 707136cea..27a96d6ba 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs @@ -2,38 +2,35 @@ namespace SharedKernel.FeatureFlags; public static class RolloutBucketHasher { - private const uint FnvOffsetBasis = 2166136261; - private const uint FnvPrime = 16777619; - - public static int ComputeBucket(string entityId) + public static int ComputeBucket(int sequenceNumber) { - unchecked - { - var hash = FnvOffsetBasis; - foreach (var c in entityId) - { - hash ^= c; - hash *= FnvPrime; - } - - return (int)(hash % 99 + 1); - } + var value = VanDerCorput(sequenceNumber); + return (int)(value * 100); } public static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) { - // Bucket 0 = always opt-in (internal testers), included in any rollout - if (bucket == 0) return true; - - // Bucket 100 = always opt-out (VIP customers), only included at 100% rollout - if (bucket == 100) return bucketStart == 0 && bucketEnd == 100; - if (bucketStart <= bucketEnd) { return bucket >= bucketStart && bucket <= bucketEnd; } - // Wrap-around case (e.g., start=90, end=10 means 90-99 and 1-10) + // Wrap-around case (e.g., start=90, end=10 means 90-99 and 0-10) return bucket >= bucketStart || bucket <= bucketEnd; } + + private static double VanDerCorput(int n) + { + double result = 0; + double denominator = 2; + + while (n > 0) + { + result += (n & 1) / denominator; + n >>= 1; + denominator *= 2; + } + + return result; + } } From 1ac231e64d5a070aa274f9fca89e64f03b2ff32d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 22:28:45 +0200 Subject: [PATCH 040/155] Replace rollout_bucket_sequence with COUNT(*) for bucket assignment --- ...0260404200000_DropRolloutBucketSequence.cs | 15 +++++++++++++ .../Features/Tenants/Commands/CreateTenant.cs | 4 ++-- .../Core/Features/Tenants/Domain/Tenant.cs | 11 ++++------ .../Tenants/Domain/TenantRepository.cs | 15 +++++++------ .../Features/Users/Commands/CreateUser.cs | 4 ++-- .../Core/Features/Users/Domain/User.cs | 11 ++++------ .../Features/Users/Domain/UserRepository.cs | 21 +++++++++---------- .../Authentication/GetUserSessionsTests.cs | 4 +--- .../Tests/Authentication/SwitchTenantTests.cs | 20 +++++------------- .../GetDashboardMrrConsistencySummaryTests.cs | 1 - .../GetUnsyncedSubscriptionsSummaryTests.cs | 1 - .../Dashboard/GetDashboardKpisTests.cs | 4 +--- .../Dashboard/GetDashboardMrrTrendTests.cs | 1 - .../GetDashboardPlanDistributionTests.cs | 1 - .../GetDashboardRecentSignupsTests.cs | 1 - .../GetDashboardRecentStripeEventsTests.cs | 1 - .../Dashboard/GetDashboardTrendsTests.cs | 4 +--- .../GetBackOfficeBillingEventsTests.cs | 1 - .../CompleteEmailLoginTests.cs | 5 +---- .../ResendEmailLoginCodeTests.cs | 3 +-- .../StartEmailLoginTests.cs | 6 ++---- .../CompleteExternalLoginTests.cs | 17 +++++---------- .../ExternalAuthenticationTestBase.cs | 3 +-- .../BackOffice/GetTenantDetailTests.cs | 3 --- .../BackOffice/GetTenantUserCountsTests.cs | 3 +-- .../Tenants/BackOffice/GetTenantUsersTests.cs | 4 +--- .../Tenants/BackOffice/GetTenantsTests.cs | 1 - .../Tests/Tenants/GetTenantsForUserTests.cs | 16 ++++---------- .../GetBackOfficeUserDetailTests.cs | 4 +--- .../GetBackOfficeUserSessionsTests.cs | 4 +--- .../BackOffice/GetBackOfficeUsersTests.cs | 4 +--- .../Tests/Users/BulkDeleteUsersTests.cs | 9 +++----- .../Tests/Users/BulkPurgeUsersTests.cs | 12 ++++------- .../Tests/Users/DeclineInvitationTests.cs | 12 +++-------- .../account/Tests/Users/DeleteUserTests.cs | 9 +++----- .../Tests/Users/EmptyRecycleBinTests.cs | 6 ++---- .../Tests/Users/GetDeletedUsersTests.cs | 3 +-- .../account/Tests/Users/GetUserByIdTests.cs | 3 +-- .../Tests/Users/GetUserSummaryTests.cs | 9 +++----- .../account/Tests/Users/GetUsersTests.cs | 6 ++---- .../account/Tests/Users/InviteUserTests.cs | 3 +-- .../account/Tests/Users/PurgeUserTests.cs | 6 ++---- .../account/Tests/Users/RestoreUserTests.cs | 6 ++---- 43 files changed, 98 insertions(+), 179 deletions(-) create mode 100644 application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs diff --git a/application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs b/application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs new file mode 100644 index 000000000..e73778602 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260404200000_DropRolloutBucketSequence")] +public sealed class DropRolloutBucketSequence : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn("rollout_bucket_sequence", "tenants"); + migrationBuilder.DropColumn("rollout_bucket_sequence", "users"); + } +} diff --git a/application/account/Core/Features/Tenants/Commands/CreateTenant.cs b/application/account/Core/Features/Tenants/Commands/CreateTenant.cs index a37bc3024..85c3d03f6 100644 --- a/application/account/Core/Features/Tenants/Commands/CreateTenant.cs +++ b/application/account/Core/Features/Tenants/Commands/CreateTenant.cs @@ -18,8 +18,8 @@ internal sealed class CreateTenantHandler(ITenantRepository tenantRepository, IS { public async Task> Handle(CreateTenantCommand command, CancellationToken cancellationToken) { - var rolloutBucketSequence = await tenantRepository.GetNextRolloutBucketSequenceUnfilteredAsync(cancellationToken); - var tenant = Tenant.Create(command.OwnerEmail, rolloutBucketSequence); + var existingCount = await tenantRepository.GetCountUnfilteredAsync(cancellationToken); + var tenant = Tenant.Create(command.OwnerEmail, existingCount); await tenantRepository.AddAsync(tenant, cancellationToken); var subscription = Subscription.Create(tenant.Id); diff --git a/application/account/Core/Features/Tenants/Domain/Tenant.cs b/application/account/Core/Features/Tenants/Domain/Tenant.cs index fc6391d8d..3d22ee5ac 100644 --- a/application/account/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account/Core/Features/Tenants/Domain/Tenant.cs @@ -6,13 +6,12 @@ namespace Account.Features.Tenants.Domain; public sealed class Tenant : SoftDeletableAggregateRoot { - private Tenant(int rolloutBucketSequence) : base(TenantId.NewId()) + private Tenant(int rolloutBucket) : base(TenantId.NewId()) { State = TenantState.Active; Plan = SubscriptionPlan.Basis; Logo = new Logo(); - RolloutBucketSequence = rolloutBucketSequence; - RolloutBucket = RolloutBucketHasher.ComputeBucket(rolloutBucketSequence); + RolloutBucket = rolloutBucket; } public string Name { get; private set; } = string.Empty; @@ -27,15 +26,13 @@ private Tenant(int rolloutBucketSequence) : base(TenantId.NewId()) public Logo Logo { get; private set; } - public int RolloutBucketSequence { get; private set; } - public int RolloutBucket { get; private set; } public int FeatureFlagVersion { get; private set; } - public static Tenant Create(string email, int rolloutBucketSequence) + public static Tenant Create(string email, int existingCount) { - var tenant = new Tenant(rolloutBucketSequence); + var tenant = new Tenant(RolloutBucketHasher.ComputeBucket(existingCount)); tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, email)); return tenant; } diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 92153cf09..34dd3a8e4 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -57,10 +57,10 @@ public interface ITenantRepository : ICrudRepository, ISoftDel Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken); /// - /// Retrieves the next rollout bucket sequence number across all tenants without applying query filters. - /// This method must query across all tenants to ensure globally unique sequence numbers. + /// Retrieves the total count of tenants without applying query filters. + /// This method is used to compute rollout buckets for new tenants. /// - Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken); + Task GetCountUnfilteredAsync(CancellationToken cancellationToken); Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken); @@ -169,13 +169,12 @@ public async Task GetMostRecentSignupsUnfilteredAsync(int limit, Cance } /// - /// Retrieves the next rollout bucket sequence number across all tenants without applying query filters. - /// This method must query across all tenants to ensure globally unique sequence numbers. + /// Retrieves the total count of tenants without applying query filters. + /// This method is used to compute rollout buckets for new tenants. /// - public async Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken) + public async Task GetCountUnfilteredAsync(CancellationToken cancellationToken) { - var maxSequence = await DbSet.IgnoreQueryFilters().MaxAsync(t => (int?)t.RolloutBucketSequence, cancellationToken); - return (maxSequence ?? -1) + 1; + return await DbSet.IgnoreQueryFilters().CountAsync(cancellationToken); } public async Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken) diff --git a/application/account/Core/Features/Users/Commands/CreateUser.cs b/application/account/Core/Features/Users/Commands/CreateUser.cs index d060d9866..91b4f0040 100644 --- a/application/account/Core/Features/Users/Commands/CreateUser.cs +++ b/application/account/Core/Features/Users/Commands/CreateUser.cs @@ -48,8 +48,8 @@ public async Task> Handle(CreateUserCommand command, Cancellation var locale = SinglePageAppConfiguration.SupportedLocalizations.Contains(command.PreferredLocale) ? command.PreferredLocale : string.Empty; - var rolloutBucketSequence = await userRepository.GetNextRolloutBucketSequenceUnfilteredAsync(cancellationToken); - var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale, rolloutBucketSequence); + var existingCount = await userRepository.GetCountUnfilteredAsync(cancellationToken); + var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale, existingCount); await userRepository.AddAsync(user, cancellationToken); var gravatar = await gravatarClient.GetGravatar(user.Id, user.Email, cancellationToken); diff --git a/application/account/Core/Features/Users/Domain/User.cs b/application/account/Core/Features/Users/Domain/User.cs index fde145c20..e69174f4f 100644 --- a/application/account/Core/Features/Users/Domain/User.cs +++ b/application/account/Core/Features/Users/Domain/User.cs @@ -8,7 +8,7 @@ namespace Account.Features.Users.Domain; public sealed class User : SoftDeletableAggregateRoot, ITenantScopedEntity { - private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int rolloutBucketSequence) + private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int rolloutBucket) : base(UserId.NewId()) { Email = email; @@ -18,8 +18,7 @@ private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed Locale = locale ?? string.Empty; Avatar = new Avatar(); ExternalIdentities = []; - RolloutBucketSequence = rolloutBucketSequence; - RolloutBucket = RolloutBucketHasher.ComputeBucket(rolloutBucketSequence); + RolloutBucket = rolloutBucket; } public string Email @@ -48,15 +47,13 @@ public string Email public ImmutableArray ExternalIdentities { get; private set; } - public int RolloutBucketSequence { get; private set; } - public int RolloutBucket { get; private set; } public TenantId TenantId { get; } - public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int rolloutBucketSequence) + public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int existingCount) { - return new User(tenantId, email, role, emailConfirmed, locale, rolloutBucketSequence); + return new User(tenantId, email, role, emailConfirmed, locale, RolloutBucketHasher.ComputeBucket(existingCount)); } public void Update(string firstName, string lastName, string title) diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 0eef84632..e19a7c347 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -103,12 +103,6 @@ CancellationToken cancellationToken /// Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); - /// - /// Retrieves the next rollout bucket sequence number across all users without applying query filters. - /// This method must query across all users to ensure globally unique sequence numbers. - /// - Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken); - /// /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. @@ -122,6 +116,12 @@ CancellationToken cancellationToken /// so the time filter runs in memory; the user count is bounded by the dashboard's audience. /// Task GetAllUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Retrieves the total count of users without applying query filters. + /// This method is used to compute rollout buckets for new users. + /// + Task GetCountUnfilteredAsync(CancellationToken cancellationToken); } public sealed class UserRepository(AccountDbContext accountDbContext, IExecutionContext executionContext, TimeProvider timeProvider) @@ -559,13 +559,12 @@ public async Task GetAllUnfilteredAsync(CancellationToken cancellationTo } /// - /// Retrieves the next rollout bucket sequence number across all users without applying query filters. - /// This method must query across all users to ensure globally unique sequence numbers. + /// Retrieves the total count of users without applying query filters. + /// This method is used to compute rollout buckets for new users. /// - public async Task GetNextRolloutBucketSequenceUnfilteredAsync(CancellationToken cancellationToken) + public async Task GetCountUnfilteredAsync(CancellationToken cancellationToken) { - var maxSequence = await DbSet.IgnoreQueryFilters().MaxAsync(u => (int?)u.RolloutBucketSequence, cancellationToken); - return (maxSequence ?? -1) + 1; + return await DbSet.IgnoreQueryFilters().CountAsync(cancellationToken); } /// diff --git a/application/account/Tests/Authentication/GetUserSessionsTests.cs b/application/account/Tests/Authentication/GetUserSessionsTests.cs index e1491bf9d..916d3cdd1 100644 --- a/application/account/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account/Tests/Authentication/GetUserSessionsTests.cs @@ -138,7 +138,6 @@ private long InsertTenant(string name) ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -164,8 +163,7 @@ private void InsertUser(long tenantId, UserId userId, string email) ("role", "Owner"), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); } diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index 35bd2894e..1ee1b21d2 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -33,7 +33,6 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -54,8 +53,7 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -119,7 +117,6 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -138,8 +135,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -185,7 +181,6 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -206,8 +201,7 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -261,7 +255,6 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -283,8 +276,7 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -345,7 +337,6 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -366,8 +357,7 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs index c0a93e505..db17bb011 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs @@ -117,7 +117,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs index 2d1c10113..01590d91d 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs @@ -67,7 +67,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index 4eaaeee2b..43511e5c1 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -267,7 +267,6 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, DateTimeOffset c ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -396,8 +395,7 @@ private UserId SeedUser(TenantId tenantId, string email, DateTimeOffset createdA ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); return userId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 1f9bfa816..40aacd324 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -116,7 +116,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs index 95fa75aec..3516cc353 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs @@ -91,7 +91,6 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan) ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs index 38fda399c..4eaf136dd 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs @@ -84,7 +84,6 @@ private void SeedTenant(string name, DateTimeOffset createdAt) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs index 4ddc5d5b8..c7b0395a1 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs @@ -112,7 +112,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs index e045ec4a7..92d6e4835 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs @@ -192,7 +192,6 @@ private TenantId SeedTenant(string name, DateTimeOffset createdAt) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -215,8 +214,7 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset createdAt) ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs index ece45958a..7ea11ab5c 100644 --- a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs +++ b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs @@ -253,7 +253,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index cb173b558..0db2344ab 100644 --- a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -221,7 +221,6 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -265,8 +264,7 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -328,7 +326,6 @@ public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_Sh ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs b/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs index 47139b091..00fba3b73 100644 --- a/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs +++ b/application/account/Tests/EmailAuthentication/ResendEmailLoginCodeTests.cs @@ -76,8 +76,7 @@ public async Task ResendEmailLoginCode_WhenUserHasDanishLocale_ShouldSendDanishR ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "da-DK"), ("external_identities", "[]"), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); var emailLoginId = await StartEmailLogin(email); diff --git a/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs index 57ad2ec74..c1209fc09 100644 --- a/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/StartEmailLoginTests.cs @@ -75,8 +75,7 @@ public async Task StartEmailLogin_WhenUserHasDanishLocale_ShouldSendDanishLoginE ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "da-DK"), ("external_identities", "[]"), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); var command = new StartEmailLoginCommand(email); @@ -230,8 +229,7 @@ public async Task StartEmailLogin_WhenUserIsSoftDeleted_ShouldReturnFakeEmailLog ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs index 27f98165e..1e84414fe 100644 --- a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -218,8 +218,7 @@ public async Task CompleteExternalLogin_WhenUserHasNoExternalIdentity_ShouldLink ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -261,8 +260,7 @@ public async Task CompleteExternalLogin_WhenInvitedUserHasNoName_ShouldUpdateNam ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -305,8 +303,7 @@ public async Task CompleteExternalLogin_WhenUserAlreadyHasName_ShouldNotOverwrit ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); var (callbackUrl, cookies) = await StartLoginFlow(); @@ -416,7 +413,6 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -461,8 +457,7 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", identities), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -480,8 +475,7 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", identities), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -604,7 +598,6 @@ public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs index a086bd2b9..25a8b10ed 100644 --- a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs +++ b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs @@ -235,8 +235,7 @@ protected UserId InsertUserWithExternalIdentity(string email, ExternalProviderTy ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", identities), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); return userId; diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index bafadd125..ecaa32568 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -31,7 +31,6 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -105,7 +104,6 @@ public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscr ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":1}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -138,7 +136,6 @@ public async Task GetTenantDetail_WhenSubscriptionHasRefundedTransaction_ShouldE ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":1}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs index 483a52571..85880d40c 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs @@ -94,8 +94,7 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenA ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs index 4510e37c9..f6b7d99a4 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs @@ -32,7 +32,6 @@ public async Task GetTenantUsers_WhenCalled_ShouldReturnUsersForThatTenantOnly() ("plan", "Basis"), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -156,8 +155,7 @@ private void SeedUser(TenantId tenantId, string email, string? firstName, string ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index b6355f001..8495301a2 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -381,7 +381,6 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); diff --git a/application/account/Tests/Tenants/GetTenantsForUserTests.cs b/application/account/Tests/Tenants/GetTenantsForUserTests.cs index 18c4fb22c..498ec1544 100644 --- a/application/account/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account/Tests/Tenants/GetTenantsForUserTests.cs @@ -33,7 +33,6 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -52,8 +51,7 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("role", nameof(UserRole.Owner)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -113,7 +111,6 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -132,8 +129,7 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -164,7 +160,6 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -183,8 +178,7 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -216,7 +210,6 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -235,8 +228,7 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("role", nameof(UserRole.Member)), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs index 4eb1fa88f..d9b8f645a 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs @@ -128,7 +128,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -153,8 +152,7 @@ private UserId SeedUser(TenantId tenantId, string email, string? firstName, stri ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); return userId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs index c87997b10..81966c546 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs @@ -127,7 +127,6 @@ private TenantId SeedTenant(string name) ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -151,8 +150,7 @@ private UserId SeedUser(TenantId tenantId, string email, UserRole role) ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); return userId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index c817fc7be..3751fb904 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -288,7 +288,6 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan = SubscriptionPla ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -347,8 +346,7 @@ private void SeedUser(TenantId tenantId, string email, string? firstName, string ("role", role.ToString()), ("locale", "en-US"), ("avatar", JsonSerializer.Serialize(new Avatar())), - ("rollout_bucket", 50), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/Users/BulkDeleteUsersTests.cs b/application/account/Tests/Users/BulkDeleteUsersTests.cs index a6ae0a70a..6de1e559d 100644 --- a/application/account/Tests/Users/BulkDeleteUsersTests.cs +++ b/application/account/Tests/Users/BulkDeleteUsersTests.cs @@ -39,8 +39,7 @@ public async Task BulkDeleteUsers_WhenUsersExist_ShouldSoftDeleteUsers() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); } @@ -152,8 +151,7 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -172,8 +170,7 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/BulkPurgeUsersTests.cs b/application/account/Tests/Users/BulkPurgeUsersTests.cs index b76c928cd..5e48b1bd7 100644 --- a/application/account/Tests/Users/BulkPurgeUsersTests.cs +++ b/application/account/Tests/Users/BulkPurgeUsersTests.cs @@ -36,8 +36,7 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -55,8 +54,7 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -74,8 +72,7 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -118,8 +115,7 @@ public async Task BulkPurgeUsers_WhenMember_ShouldReturnForbidden() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/DeclineInvitationTests.cs b/application/account/Tests/Users/DeclineInvitationTests.cs index 2b21cf848..7a7ebca18 100644 --- a/application/account/Tests/Users/DeclineInvitationTests.cs +++ b/application/account/Tests/Users/DeclineInvitationTests.cs @@ -32,7 +32,6 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -52,8 +51,7 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("role", nameof(UserRole.Member)), ("locale", ""), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -110,7 +108,6 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -124,7 +121,6 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0), ("feature_flag_version", 0) ] ); @@ -144,8 +140,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("role", nameof(UserRole.Member)), ("locale", ""), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -164,8 +159,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("role", nameof(UserRole.Member)), ("locale", ""), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/DeleteUserTests.cs b/application/account/Tests/Users/DeleteUserTests.cs index 581bfd82a..1c28dd9ce 100644 --- a/application/account/Tests/Users/DeleteUserTests.cs +++ b/application/account/Tests/Users/DeleteUserTests.cs @@ -46,8 +46,7 @@ public async Task DeleteUser_WhenUserExists_ShouldSoftDeleteUser() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -94,8 +93,7 @@ public async Task DeleteUser_WhenUserHasEmailLoginHistory_ShouldSoftDeleteUserAn ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -145,8 +143,7 @@ public async Task DeleteUser_WhenUserNeverConfirmedEmail_ShouldSoftDeleteUser() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/EmptyRecycleBinTests.cs b/application/account/Tests/Users/EmptyRecycleBinTests.cs index 77e40662e..781e31e89 100644 --- a/application/account/Tests/Users/EmptyRecycleBinTests.cs +++ b/application/account/Tests/Users/EmptyRecycleBinTests.cs @@ -34,8 +34,7 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -53,8 +52,7 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/GetDeletedUsersTests.cs b/application/account/Tests/Users/GetDeletedUsersTests.cs index 4d3d7fa35..21e7f3ec2 100644 --- a/application/account/Tests/Users/GetDeletedUsersTests.cs +++ b/application/account/Tests/Users/GetDeletedUsersTests.cs @@ -34,8 +34,7 @@ public async Task GetDeletedUsers_WhenOwner_ShouldReturnDeletedUsers() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/GetUserByIdTests.cs b/application/account/Tests/Users/GetUserByIdTests.cs index d55da8548..0024c63c7 100644 --- a/application/account/Tests/Users/GetUserByIdTests.cs +++ b/application/account/Tests/Users/GetUserByIdTests.cs @@ -31,8 +31,7 @@ public GetUserByIdTests() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); } diff --git a/application/account/Tests/Users/GetUserSummaryTests.cs b/application/account/Tests/Users/GetUserSummaryTests.cs index e4a67173a..042e92cc1 100644 --- a/application/account/Tests/Users/GetUserSummaryTests.cs +++ b/application/account/Tests/Users/GetUserSummaryTests.cs @@ -38,8 +38,7 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -59,8 +58,7 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -80,8 +78,7 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/GetUsersTests.cs b/application/account/Tests/Users/GetUsersTests.cs index 3bf4d1b2e..0a0ac5609 100644 --- a/application/account/Tests/Users/GetUsersTests.cs +++ b/application/account/Tests/Users/GetUsersTests.cs @@ -33,8 +33,7 @@ public GetUsersTests() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); Connection.Insert("users", [ @@ -51,8 +50,7 @@ public GetUsersTests() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); } diff --git a/application/account/Tests/Users/InviteUserTests.cs b/application/account/Tests/Users/InviteUserTests.cs index 3700842d3..834eaf2b1 100644 --- a/application/account/Tests/Users/InviteUserTests.cs +++ b/application/account/Tests/Users/InviteUserTests.cs @@ -178,8 +178,7 @@ public async Task InviteUser_WhenDeletedUserExists_ShouldReturnBadRequest() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/PurgeUserTests.cs b/application/account/Tests/Users/PurgeUserTests.cs index f75cfae14..3761f7c23 100644 --- a/application/account/Tests/Users/PurgeUserTests.cs +++ b/application/account/Tests/Users/PurgeUserTests.cs @@ -32,8 +32,7 @@ public async Task PurgeUser_WhenOwnerDeletesSoftDeletedUser_ShouldSucceed() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -95,8 +94,7 @@ public async Task PurgeUser_WhenUserNotDeleted_ShouldReturnNotFound() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/RestoreUserTests.cs b/application/account/Tests/Users/RestoreUserTests.cs index 12d76a8b4..af9485568 100644 --- a/application/account/Tests/Users/RestoreUserTests.cs +++ b/application/account/Tests/Users/RestoreUserTests.cs @@ -32,8 +32,7 @@ public async Task RestoreUser_WhenOwnerRestoresDeletedUser_ShouldSucceed() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); @@ -95,8 +94,7 @@ public async Task RestoreUser_WhenUserNotDeleted_ShouldReturnNotFound() ("avatar", JsonSerializer.Serialize(new Avatar())), ("locale", "en-US"), ("external_identities", "[]"), - ("rollout_bucket", 42), - ("rollout_bucket_sequence", 0) + ("rollout_bucket", 42) ] ); From db39839fd629673f9c382277ea2a142a33eec1f7 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 4 Apr 2026 22:32:05 +0200 Subject: [PATCH 041/155] Add plan-based feature flags with evaluation at token refresh --- .../routes/feature-flags/$flagKey.tsx | 92 ++++++++++- .../-components/TenantOverrideRow.tsx | 2 + .../-components/UserOverrideRow.tsx | 2 + .../routes/feature-flags/-components/types.ts | 5 +- .../BackOffice/routes/feature-flags/index.tsx | 46 ++++-- .../shared/translations/locale/da-DK.po | 12 +- application/account/Core/Configuration.cs | 1 + .../20260310000100_SeedFeatureFlags.cs | 12 +- ...0260404100100_SeedPlanBasedFeatureFlags.cs | 81 ++++++++++ .../20260404100000_AddSourceToFeatureFlags.cs | 22 +++ .../FeatureFlags/Domain/FeatureFlag.cs | 15 +- .../Domain/FeatureFlagRepository.cs | 9 ++ .../FeatureFlags/Domain/FeatureFlagTypes.cs | 9 ++ .../PlanBasedFeatureFlagService.cs | 81 ++++++++++ .../FeatureFlags/Queries/GetFeatureFlags.cs | 5 +- .../Shared/ProcessPendingStripeEvents.cs | 9 ++ .../Features/Users/Shared/UserInfoFactory.cs | 6 +- .../FeatureFlagEvaluationServiceTests.cs | 3 +- .../Tests/FeatureFlags/FeatureFlagTests.cs | 15 +- .../PlanBasedFeatureFlagServiceTests.cs | 144 ++++++++++++++++++ .../tests/e2e/feature-flag-flows.spec.ts | 6 +- .../FeatureFlags/FeatureFlagDefinition.cs | 3 +- .../SharedKernel/FeatureFlags/FeatureFlags.cs | 26 +++- .../SharedKernel/FeatureFlags/PlanTier.cs | 10 ++ 24 files changed, 573 insertions(+), 43 deletions(-) create mode 100644 application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs create mode 100644 application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs create mode 100644 application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs create mode 100644 application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs create mode 100644 application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/PlanTier.cs diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 7b6bb50c2..127803d1f 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -1,14 +1,22 @@ import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Badge } from "@repo/ui/components/Badge"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeftIcon } from "lucide-react"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; import { api } from "@/shared/lib/api/client"; -import type { GetFeatureFlagsResponse, GetFlagTenantsResponse } from "./-components/types"; +import type { + FeatureFlagInfo, + FlagTenantInfo, + GetFeatureFlagsResponse, + GetFlagTenantsResponse +} from "./-components/types"; import { FlagInfoSection } from "./-components/FlagInfoSection"; import { getFlagDescription, getFlagName } from "./-components/flagLabels"; @@ -41,6 +49,7 @@ export default function FlagDetailPage() { isLoading: boolean; }; + const isPlanFlag = flag?.requiredPlan != null; const isLoading = isLoadingFlags || (flag?.scope === "Tenant" && isLoadingTenants); const flagName = flag ? getFlagName(flag.key) : flagKey; const description = flag ? getFlagDescription(flag.key) || flag.description : ""; @@ -67,8 +76,8 @@ export default function FlagDetailPage() { ) : flag ? (
- - {flag.scope === "Tenant" && ( + {isPlanFlag ? : } + {flag.scope === "Tenant" && !isPlanFlag && ( )} + {flag.scope === "Tenant" && isPlanFlag && ( + + )} {flag.scope === "User" && ( ) { + return ( +
+
+
+ + Name: {flag.key} + + + Required plan: {flag.requiredPlan} + +
+ {flag.isActive ? t`Active` : t`Inactive`} +
+

+ + This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan + or higher. + +

+
+ ); +} + +function PlanFlagTenantsSection({ flagKey, tenants }: Readonly<{ flagKey: string; tenants: FlagTenantInfo[] }>) { + return ( +
+
+

+ Account status +

+

+ + Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are + available for plan-managed flags. + +

+
+
+ + + + Account ID + + + Account + + + Plan + + + Status + + + + + {tenants.map((tenant) => ( + + {tenant.tenantId} + {tenant.tenantName} + {tenant.plan} + + + {tenant.isEnabled ? t`Enabled` : t`Disabled`} + + + + ))} + +
+
+ ); +} + function FlagDetailSkeleton() { return (
diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index bc45e4171..b406af398 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -18,6 +18,8 @@ function getSourceLabel(source: string): string { return t`Manual override`; case "ab_rollout": return t`A/B rollout`; + case "plan": + return t`Plan`; case "default": return t`Default`; default: diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index 59914039a..216b5e252 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -19,6 +19,8 @@ function getSourceLabel(source: string): string { return t`Manual override`; case "ab_rollout": return t`A/B rollout`; + case "plan": + return t`Plan`; case "default": return t`Default`; default: diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 5315a43f3..08c1c48df 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -15,6 +15,7 @@ export interface FeatureFlagInfo { rolloutPercentage: number | null; isActive: boolean; createdAt: string | null; + requiredPlan: string | null; } export interface GetFeatureFlagsResponse { @@ -26,7 +27,7 @@ export interface FlagTenantInfo { tenantName: string; plan: string; isEnabled: boolean; - source: "manual_override" | "ab_rollout" | "default"; + source: "manual_override" | "ab_rollout" | "plan" | "default"; rolloutBucket: number; } @@ -40,7 +41,7 @@ export interface FlagUserInfo { email: string; tenantName: string; isEnabled: boolean; - source: "manual_override" | "ab_rollout" | "default"; + source: "manual_override" | "ab_rollout" | "plan" | "default"; rolloutBucket: number; } diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index acd26d9cd..90a6be760 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -21,8 +21,10 @@ export const Route = createFileRoute("/feature-flags/")({ component: FeatureFlagsPage }); +type FlagGroupKey = "Tenant" | "Plan" | "User" | "System"; + interface FlagGroup { - scope: FeatureFlagScope; + groupKey: FlagGroupKey; label: string; flags: FeatureFlagInfo[]; } @@ -35,17 +37,22 @@ export default function FeatureFlagsPage() { const groups = useMemo(() => { if (!data?.flags) return []; - const scopeOrder: FeatureFlagScope[] = ["Tenant", "User", "System"]; - const scopeLabels: Record = { + const groupOrder: FlagGroupKey[] = ["Tenant", "Plan", "User", "System"]; + const groupLabels: Record = { Tenant: t`Account flags`, + Plan: t`Plan flags`, User: t`User flags`, System: t`System flags` }; - return scopeOrder - .map((scope) => ({ - scope, - label: scopeLabels[scope], - flags: data.flags.filter((flag) => flag.scope === scope) + return groupOrder + .map((groupKey) => ({ + groupKey, + label: groupLabels[groupKey], + flags: data.flags.filter((flag) => { + if (groupKey === "Plan") return flag.scope === "Tenant" && flag.requiredPlan != null; + if (groupKey === "Tenant") return flag.scope === "Tenant" && flag.requiredPlan == null; + return flag.scope === groupKey; + }) })) .filter((group) => group.flags.length > 0); }, [data?.flags]); @@ -64,14 +71,17 @@ export default function FeatureFlagsPage() { function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { const navigate = useNavigate(); - const hasDetail = (scope: FeatureFlagScope) => scope === "Tenant" || scope === "User"; + const hasDetail = (groupKey: FlagGroupKey, scope: FeatureFlagScope) => + groupKey === "Plan" || scope === "Tenant" || scope === "User"; return (
{groups.map((group) => { - const showRollout = group.scope !== "System"; + const isPlanGroup = group.groupKey === "Plan"; + const isSystemGroup = group.groupKey === "System"; + const showRollout = !isSystemGroup && !isPlanGroup; return ( -
+

{group.label}

@@ -79,6 +89,11 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { Name + {isPlanGroup && ( + + Required plan + + )} {showRollout && ( Rollout @@ -93,9 +108,9 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { {group.flags.map((flag) => ( navigate({ to: "/feature-flags/$flagKey", params: { flagKey: flag.key } }) : undefined } @@ -111,6 +126,11 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { + {isPlanGroup && ( + + {flag.requiredPlan} + + )} {showRollout && ( {flag.rolloutPercentage !== null ? ( diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 34bf08fed..db091f1cd 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -127,9 +127,16 @@ msgstr "konti" msgid "Accounts" msgstr "Konti" +msgid "Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are available for plan-managed flags." +msgstr "" + msgid "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." msgstr "" +#. placeholder {0}: getFlagName(flagKey) +msgid "Accounts for {0}" +msgstr "" + msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." @@ -276,7 +283,7 @@ msgstr "Oprettet" #. placeholder {0}: formatDate(user.createdAt, false, false, true) #. placeholder {1}: formatDate(user.createdAt) msgid "Created <0>{0}<1>{1}" -msgstr "" +msgstr "Oprettet <0>{0}<1>{1}" msgid "Credit note" msgstr "Kreditnota" @@ -748,6 +755,9 @@ msgstr "" msgid "Plan distribution" msgstr "Plan-fordeling" +msgid "Plan flags" +msgstr "" + msgid "Plan transition" msgstr "" diff --git a/application/account/Core/Configuration.cs b/application/account/Core/Configuration.cs index 2b2a3aa59..971f2886f 100644 --- a/application/account/Core/Configuration.cs +++ b/application/account/Core/Configuration.cs @@ -72,6 +72,7 @@ public IServiceCollection AddAccountServices() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs index d5ae0e5d5..fa2359904 100644 --- a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs +++ b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs @@ -25,20 +25,24 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) seededCount++; var id = FeatureFlagId.NewId().Value; + var source = flag.RequiredPlan is not null ? "Plan" : "Manual"; + await dbContext.Database.ExecuteSqlRawAsync( """ - INSERT INTO feature_flags (id, flag_key, tenant_id, user_id, created_at, modified_at, enabled_at, disabled_at, bucket_start, bucket_end, configurable_by_tenant, configurable_by_user) - VALUES (@id, @flagKey, NULL, NULL, @now, NULL, NULL, NULL, NULL, NULL, @configurableByTenant, @configurableByUser) + INSERT INTO feature_flags (id, flag_key, tenant_id, user_id, created_at, modified_at, enabled_at, disabled_at, bucket_start, bucket_end, configurable_by_tenant, configurable_by_user, source) + VALUES (@id, @flagKey, NULL, NULL, @now, NULL, NULL, NULL, NULL, NULL, @configurableByTenant, @configurableByUser, @source) ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET configurable_by_tenant = @configurableByTenant, - configurable_by_user = @configurableByUser + configurable_by_user = @configurableByUser, + source = @source """, [ new NpgsqlParameter("@id", id), new NpgsqlParameter("@flagKey", flag.Key), new NpgsqlParameter("@now", now), new NpgsqlParameter("@configurableByTenant", flag.ConfigurableByTenant), - new NpgsqlParameter("@configurableByUser", flag.ConfigurableByUser) + new NpgsqlParameter("@configurableByUser", flag.ConfigurableByUser), + new NpgsqlParameter("@source", source) ], cancellationToken ); diff --git a/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs new file mode 100644 index 000000000..6cd60f09f --- /dev/null +++ b/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs @@ -0,0 +1,81 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Subscriptions.Domain; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SharedKernel.Database; +using SharedKernel.FeatureFlags; + +namespace Account.Database.DataMigrations; + +public sealed class SeedPlanBasedFeatureFlags(AccountDbContext dbContext) : IDataMigration +{ + public string Id => "20260404100100_SeedPlanBasedFeatureFlags"; + + public TimeSpan Timeout { get; } = TimeSpan.FromMinutes(5); + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var planFlags = FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); + if (planFlags.Length == 0) return "No plan-based feature flags defined"; + + var planTierMapping = new Dictionary + { + [nameof(SubscriptionPlan.Basis)] = PlanTier.Free, + [nameof(SubscriptionPlan.Standard)] = PlanTier.Standard, + [nameof(SubscriptionPlan.Premium)] = PlanTier.Premium + }; + + var tenants = await dbContext.Database.SqlQueryRaw( + """ + SELECT t.id AS "TenantId", s.plan AS "Plan" + FROM tenants t + JOIN subscriptions s ON s.tenant_id = t.id + WHERE t.deleted_at IS NULL + """ + ).ToArrayAsync(cancellationToken); + + var now = DateTimeOffset.UtcNow; + var seededCount = 0; + + foreach (var tenant in tenants) + { + if (!planTierMapping.TryGetValue(tenant.Plan, out var tenantPlanTier)) continue; + + foreach (var flag in planFlags) + { + var shouldBeEnabled = tenantPlanTier >= flag.RequiredPlan!.Value; + var id = FeatureFlagId.NewId().Value; + + await dbContext.Database.ExecuteSqlRawAsync( + """ + INSERT INTO feature_flags (id, flag_key, tenant_id, user_id, created_at, modified_at, enabled_at, disabled_at, bucket_start, bucket_end, configurable_by_tenant, configurable_by_user, source) + VALUES (@id, @flagKey, @tenantId, NULL, @now, NULL, @enabledAt, @disabledAt, NULL, NULL, false, false, 'Plan') + ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET + enabled_at = CASE WHEN feature_flags.source = 'Plan' THEN @enabledAt ELSE feature_flags.enabled_at END, + disabled_at = CASE WHEN feature_flags.source = 'Plan' THEN @disabledAt ELSE feature_flags.disabled_at END, + source = CASE WHEN feature_flags.source = 'Manual' THEN 'Plan' ELSE feature_flags.source END + """, + [ + new NpgsqlParameter("@id", id), + new NpgsqlParameter("@flagKey", flag.Key), + new NpgsqlParameter("@tenantId", tenant.TenantId), + new NpgsqlParameter("@now", now), + new NpgsqlParameter("@enabledAt", shouldBeEnabled ? now : DBNull.Value), + new NpgsqlParameter("@disabledAt", shouldBeEnabled ? DBNull.Value : now) + ], + cancellationToken + ); + + seededCount++; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return $"Seeded {seededCount} plan-based feature flag overrides across {tenants.Length} tenants"; + } + + [UsedImplicitly] + private sealed record TenantSubscriptionInfo(long TenantId, string Plan); +} diff --git a/application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs b/application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs new file mode 100644 index 000000000..2153c2613 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260404100000_AddSourceToFeatureFlags")] +public sealed class AddSourceToFeatureFlags : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("source", "feature_flags", "text", nullable: false, defaultValue: "Manual"); + + migrationBuilder.Sql( + """ + UPDATE feature_flags + SET source = 'AbRollout' + WHERE bucket_start IS NOT NULL AND bucket_end IS NOT NULL AND tenant_id IS NULL AND user_id IS NULL + """ + ); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index dca6be79e..2d98ae9f4 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -12,12 +12,13 @@ private FeatureFlag() : base(FeatureFlagId.NewId()) FlagKey = string.Empty; } - private FeatureFlag(string flagKey, long? tenantId, string? userId) + private FeatureFlag(string flagKey, long? tenantId, string? userId, FeatureFlagSource source) : base(FeatureFlagId.NewId()) { FlagKey = flagKey; TenantId = tenantId; UserId = userId; + Source = source; } public string FlagKey { get; private set; } @@ -40,19 +41,21 @@ private FeatureFlag(string flagKey, long? tenantId, string? userId) [UsedImplicitly] public bool ConfigurableByUser { get; private set; } - public static FeatureFlag Create(string flagKey) + public FeatureFlagSource Source { get; private set; } + + public static FeatureFlag Create(string flagKey, FeatureFlagSource source = FeatureFlagSource.Manual) { - return new FeatureFlag(flagKey, null, null); + return new FeatureFlag(flagKey, null, null, source); } - public static FeatureFlag CreateTenantOverride(string flagKey, long tenantId) + public static FeatureFlag CreateTenantOverride(string flagKey, long tenantId, FeatureFlagSource source = FeatureFlagSource.Manual) { - return new FeatureFlag(flagKey, tenantId, null); + return new FeatureFlag(flagKey, tenantId, null, source); } public static FeatureFlag CreateUserOverride(string flagKey, long tenantId, string userId) { - return new FeatureFlag(flagKey, tenantId, userId); + return new FeatureFlag(flagKey, tenantId, userId, FeatureFlagSource.Manual); } public void Activate(DateTimeOffset now) diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index e0d045595..873ebf801 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -16,6 +16,8 @@ public interface IFeatureFlagRepository : ICrudRepository GetByKeyAndScopeAsync(string flagKey, long? tenantId, string? userId, CancellationToken cancellationToken); Task GetUserOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); + + Task GetPlanBasedOverridesForTenantAsync(long tenantId, CancellationToken cancellationToken); } internal sealed class FeatureFlagRepository(AccountDbContext accountDbContext) @@ -54,4 +56,11 @@ public async Task GetUserOverridesForFlagAsync(string flagKey, Ca .Where(f => f.FlagKey == flagKey && f.UserId != null) .ToArrayAsync(cancellationToken); } + + public async Task GetPlanBasedOverridesForTenantAsync(long tenantId, CancellationToken cancellationToken) + { + return await DbSet + .Where(f => f.TenantId == tenantId && f.UserId == null && f.Source == FeatureFlagSource.Plan) + .ToArrayAsync(cancellationToken); + } } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs new file mode 100644 index 000000000..a58fb7e0f --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs @@ -0,0 +1,9 @@ +namespace Account.Features.FeatureFlags.Domain; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FeatureFlagSource +{ + Manual, + Plan, + AbRollout +} diff --git a/application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs b/application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs new file mode 100644 index 000000000..2df7067c8 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs @@ -0,0 +1,81 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags; + +public sealed class PlanBasedFeatureFlagService(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider) +{ + public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, SubscriptionPlan subscriptionPlan, CancellationToken cancellationToken) + { + var planTier = MapToPlanTier(subscriptionPlan); + var planFlagDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); + + if (planFlagDefinitions.Length == 0) return; + + var existingOverrides = await featureFlagRepository.GetPlanBasedOverridesForTenantAsync(tenantId.Value, cancellationToken); + var overridesByKey = existingOverrides.ToDictionary(f => f.FlagKey); + var now = timeProvider.GetUtcNow(); + var changed = false; + + foreach (var definition in planFlagDefinitions) + { + var shouldBeEnabled = planTier >= definition.RequiredPlan!.Value; + overridesByKey.TryGetValue(definition.Key, out var existingOverride); + + if (shouldBeEnabled) + { + if (existingOverride is null) + { + var flag = FeatureFlag.CreateTenantOverride(definition.Key, tenantId.Value, FeatureFlagSource.Plan); + flag.Activate(now); + await featureFlagRepository.AddAsync(flag, cancellationToken); + changed = true; + } + else if (!IsActive(existingOverride)) + { + existingOverride.Activate(now); + featureFlagRepository.Update(existingOverride); + changed = true; + } + } + else + { + if (existingOverride is not null && IsActive(existingOverride)) + { + existingOverride.Deactivate(now); + featureFlagRepository.Update(existingOverride); + changed = true; + } + } + } + + if (changed) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(tenantId, cancellationToken); + if (tenant is not null) + { + tenant.IncrementFeatureFlagVersion(); + tenantRepository.Update(tenant); + } + } + } + + private static bool IsActive(FeatureFlag flag) + { + return flag.EnabledAt is not null && (flag.DisabledAt is null || flag.EnabledAt > flag.DisabledAt); + } + + private static PlanTier MapToPlanTier(SubscriptionPlan plan) + { + return plan switch + { + SubscriptionPlan.Basis => PlanTier.Free, + SubscriptionPlan.Standard => PlanTier.Standard, + SubscriptionPlan.Premium => PlanTier.Premium, + _ => PlanTier.Free + }; + } +} diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs index 0015e5cda..7f37efe5b 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs @@ -21,6 +21,7 @@ public sealed record FeatureFlagInfo( bool IsAbTestEligible, bool ConfigurableByTenant, bool ConfigurableByUser, + string? RequiredPlan, DateTimeOffset? CreatedAt, DateTimeOffset? EnabledAt, DateTimeOffset? DisabledAt, @@ -46,7 +47,7 @@ public async Task> Handle(GetFeatureFlagsQuery r var isSystemFlagActive = IsSystemFlagEnabled(definition.Key); return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, - definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, + definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, definition.RequiredPlan?.ToString(), null, null, null, null, null, null, isSystemFlagActive ); } @@ -63,7 +64,7 @@ public async Task> Handle(GetFeatureFlagsQuery r return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, - definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, + definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, definition.RequiredPlan?.ToString(), createdAt, enabledAt, disabledAt, bucketStart, bucketEnd, rolloutPercentage, isActive ); } diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 4c849e184..d32fecbeb 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -1,6 +1,7 @@ using System.Data; using System.Globalization; using Account.Database; +using Account.Features.FeatureFlags; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; using Account.Integrations.Stripe; @@ -30,6 +31,7 @@ public sealed class ProcessPendingStripeEvents( StripeClientFactory stripeClientFactory, TimeProvider timeProvider, ITelemetryEventsCollector events, + PlanBasedFeatureFlagService planBasedFeatureFlagService, TelemetryClient telemetryClient, ILogger logger ) @@ -78,6 +80,8 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, PendingWebhook var tenant = (await tenantRepository.GetByIdUnfilteredAsync(subscription.TenantId, cancellationToken))!; + var previousPlan = subscription.Plan; + // The hot path runs whenever a webhook just landed (justAcknowledgedEvent is not null), an admin // reconcile click sets forceSync, or accumulated Pending stripe_events rows from a prior partial // delivery need a self-heal sync (the tenant-side process-pending-events polling endpoint @@ -106,6 +110,11 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, PendingWebhook } } + if (subscription.Plan != previousPlan) + { + await planBasedFeatureFlagService.EvaluatePlanFlagsForTenantAsync(subscription.TenantId, subscription.Plan, cancellationToken); + } + await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); diff --git a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs index cc4e8587a..55edd5313 100644 --- a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs @@ -12,7 +12,7 @@ namespace Account.Features.Users.Shared; /// Factory for creating UserInfo instances with tenant information. /// Centralizes the logic for creating UserInfo to follow SRP and avoid duplication. /// -public sealed class UserInfoFactory(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, FeatureFlagEvaluationService featureFlagEvaluationService) +public sealed class UserInfoFactory(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, FeatureFlagEvaluationService featureFlagEvaluationService, PlanBasedFeatureFlagService planBasedFeatureFlagService) { /// /// Creates a UserInfo instance from a User entity, including tenant name. @@ -25,6 +25,8 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(user.TenantId, cancellationToken); + await planBasedFeatureFlagService.EvaluatePlanFlagsForTenantAsync(tenant.Id, subscription!.Plan, cancellationToken); + var enabledFlags = await featureFlagEvaluationService.EvaluateAsync(tenant.Id.Value, user.Id.Value, tenant.RolloutBucket, user.RolloutBucket, cancellationToken); return new UserInfo @@ -41,7 +43,7 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se AvatarUrl = user.Avatar.Url, TenantName = tenant.Name, TenantLogoUrl = tenant.Logo.Url, - SubscriptionPlan = subscription!.Plan.ToString(), + SubscriptionPlan = subscription.Plan.ToString(), Locale = user.Locale, IsInternalUser = user.IsInternalUser, FeatureFlags = new HashSet(enabledFlags), diff --git a/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs index 1d84fcbcc..3978c2d0e 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs @@ -244,7 +244,8 @@ tenantId is null && userId is null ("bucket_start", bucketStart), ("bucket_end", bucketEnd), ("configurable_by_tenant", false), - ("configurable_by_user", false) + ("configurable_by_user", false), + ("source", "Manual") ] ); } diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 09a70039c..ed25271bf 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -240,7 +240,8 @@ public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDelet ("bucket_start", null), ("bucket_end", null), ("configurable_by_tenant", false), - ("configurable_by_user", false) + ("configurable_by_user", false), + ("source", "Manual") ] ); @@ -431,7 +432,8 @@ public async Task RemoveUserFeatureFlagOverride_WhenOverrideExists_ShouldDeleteR ("bucket_start", null), ("bucket_end", null), ("configurable_by_tenant", false), - ("configurable_by_user", false) + ("configurable_by_user", false), + ("source", "Manual") ] ); @@ -520,7 +522,8 @@ public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOve ("bucket_start", null), ("bucket_end", null), ("configurable_by_tenant", false), - ("configurable_by_user", false) + ("configurable_by_user", false), + ("source", "Manual") ] ); @@ -769,7 +772,8 @@ public async Task GetFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrid ("bucket_start", null), ("bucket_end", null), ("configurable_by_tenant", false), - ("configurable_by_user", false) + ("configurable_by_user", false), + ("source", "Manual") ] ); @@ -843,7 +847,8 @@ public async Task GetFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActi ("bucket_start", null), ("bucket_end", null), ("configurable_by_tenant", false), - ("configurable_by_user", false) + ("configurable_by_user", false), + ("source", "Manual") ] ); diff --git a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs new file mode 100644 index 000000000..caa472bf2 --- /dev/null +++ b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs @@ -0,0 +1,144 @@ +using Account.Database; +using Account.Features.FeatureFlags; +using Account.Features.Subscriptions.Domain; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.FeatureFlags; + +public sealed class PlanBasedFeatureFlagServiceTests : EndpointBaseTest +{ + private readonly AccountDbContext _dbContext; + private readonly PlanBasedFeatureFlagService _service; + + public PlanBasedFeatureFlagServiceTests() + { + var scope = Provider.CreateScope(); + _service = scope.ServiceProvider.GetRequiredService(); + _dbContext = scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task EvaluatePlanFlags_WhenPremiumPlan_ShouldActivatePremiumFlags() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + + // Act + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + // Assert + var ssoEnabled = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", + [new { tenantId = tenantId.Value }] + ); + ssoEnabled.Should().NotBeNullOrEmpty(); + + var source = Connection.ExecuteScalar( + "SELECT source FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", + [new { tenantId = tenantId.Value }] + ); + source.Should().Be("Plan"); + } + + [Fact] + public async Task EvaluatePlanFlags_WhenFreePlan_ShouldDeactivatePremiumFlags() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + // Act + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Basis, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + // Assert + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", + [new { tenantId = tenantId.Value }] + ); + disabledAt.Should().NotBeNullOrEmpty(); + + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", + [new { tenantId = tenantId.Value }] + ); + enabledAt.Should().BeNull(); + } + + [Fact] + public async Task EvaluatePlanFlags_WhenAlreadyActive_ShouldBeIdempotent() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + var firstEnabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", + [new { tenantId = tenantId.Value }] + ); + + // Act + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + // Assert + var secondEnabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", + [new { tenantId = tenantId.Value }] + ); + secondEnabledAt.Should().Be(firstEnabledAt); + } + + [Fact] + public async Task EvaluatePlanFlags_WhenUpgraded_ShouldIncrementFeatureFlagVersion() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + var versionBefore = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", + [new { tenantId = tenantId.Value }] + ); + + // Act + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + // Assert + var versionAfter = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", + [new { tenantId = tenantId.Value }] + ); + versionAfter.Should().Be(versionBefore + 1); + } + + [Fact] + public async Task EvaluatePlanFlags_WhenNoChange_ShouldNotIncrementFeatureFlagVersion() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + var versionAfterFirst = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", + [new { tenantId = tenantId.Value }] + ); + + // Act + await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); + await _dbContext.SaveChangesAsync(); + + // Assert + var versionAfterSecond = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", + [new { tenantId = tenantId.Value }] + ); + versionAfterSecond.Should().Be(versionAfterFirst); + } +} diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index d91f2b94c..ff7c80630 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -11,7 +11,7 @@ test.describe("@smoke", () => { * FEATURE FLAG SYSTEM E2E TEST * * Tests the full feature flag management flow: - * - Back-office flag list: view flags grouped by scope (Account, User, System) + * - Back-office flag list: view flags grouped by scope (Account, Plan, User, System) * - Back-office flag detail: navigate into account-scoped flag, toggle account override twice, set A/B rollout percentage * - Account settings: verify Features section, toggle account-scoped custom branding flag * - User preferences: verify Beta features section, toggle user-scoped compact view flag @@ -42,9 +42,11 @@ test.describe("@smoke", () => { const accountTable = page.getByRole("table", { name: "Account flags" }); await expect(accountTable.getByText("Beta features")).toBeVisible(); - await expect(accountTable.getByText("Single sign-on")).toBeVisible(); await expect(accountTable.getByText("Custom branding")).toBeVisible(); + const planTable = page.getByRole("table", { name: "Plan flags" }); + await expect(planTable.getByText("Single sign-on")).toBeVisible(); + const userTable = page.getByRole("table", { name: "User flags" }); await expect(userTable.getByText("Compact view")).toBeVisible(); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs index bc0b76898..80aff6e63 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs @@ -11,5 +11,6 @@ public sealed record FeatureFlagDefinition( bool ConfigurableByTenant = false, bool ConfigurableByUser = false, bool TrackInTelemetry = false, - string? TelemetryName = null + string? TelemetryName = null, + PlanTier? RequiredPlan = null ); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs index b2b4e5485..7306193e4 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -30,7 +30,8 @@ public static class FeatureFlags "sso", FeatureFlagScope.Tenant, FeatureFlagAdminLevel.SystemAdmin, - "Enables single sign-on for tenants" + "Enables single sign-on for tenants", + RequiredPlan: PlanTier.Premium ); public static readonly FeatureFlagDefinition CustomBranding = new( @@ -116,6 +117,29 @@ private static void ValidateFlags() throw new InvalidOperationException($"Feature flag '{flag.Key}' cannot be both ConfigurableByTenant and IsAbTestEligible."); } + if (flag.RequiredPlan is not null) + { + if (flag.Scope != FeatureFlagScope.Tenant) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan must have Tenant scope."); + } + + if (flag.ConfigurableByTenant) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan cannot be ConfigurableByTenant."); + } + + if (flag.ConfigurableByUser) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan cannot be ConfigurableByUser."); + } + + if (flag.IsAbTestEligible) + { + throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan cannot be IsAbTestEligible."); + } + } + if (flag.ParentDependency is not null) { if (!flagsByKey.TryGetValue(flag.ParentDependency, out var parent)) diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/PlanTier.cs b/application/shared-kernel/SharedKernel/FeatureFlags/PlanTier.cs new file mode 100644 index 000000000..aa5f5b643 --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/PlanTier.cs @@ -0,0 +1,10 @@ +namespace SharedKernel.FeatureFlags; + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PlanTier +{ + Free = 0, + Standard = 1, + Premium = 2 +} From a257bf061ca3283580c94c2a6a71b3dd1cf38e98 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 5 Apr 2026 08:47:59 +0200 Subject: [PATCH 042/155] Add search and plan grouping to plan flag detail page --- .../routes/feature-flags/$flagKey.tsx | 87 +--------- .../-components/PlanFlagSections.tsx | 154 ++++++++++++++++++ .../-components/TenantOverridesSection.tsx | 2 +- .../shared/translations/locale/da-DK.po | 9 +- 4 files changed, 163 insertions(+), 89 deletions(-) create mode 100644 application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 127803d1f..8c2b7b9a6 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -1,25 +1,18 @@ import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; -import { Badge } from "@repo/ui/components/Badge"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeftIcon } from "lucide-react"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; import { api } from "@/shared/lib/api/client"; -import type { - FeatureFlagInfo, - FlagTenantInfo, - GetFeatureFlagsResponse, - GetFlagTenantsResponse -} from "./-components/types"; +import type { GetFeatureFlagsResponse, GetFlagTenantsResponse } from "./-components/types"; import { FlagInfoSection } from "./-components/FlagInfoSection"; import { getFlagDescription, getFlagName } from "./-components/flagLabels"; +import { PlanFlagInfoSection, PlanFlagTenantsSection } from "./-components/PlanFlagSections"; import { ScopeIcon } from "./-components/ScopeIcon"; import { TenantOverridesSection } from "./-components/TenantOverridesSection"; import { UserOverridesSection } from "./-components/UserOverridesSection"; @@ -92,7 +85,7 @@ export default function FlagDetailPage() { /> )} {flag.scope === "Tenant" && isPlanFlag && ( - + )} {flag.scope === "User" && ( ) { - return ( -
-
-
- - Name: {flag.key} - - - Required plan: {flag.requiredPlan} - -
- {flag.isActive ? t`Active` : t`Inactive`} -
-

- - This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan - or higher. - -

-
- ); -} - -function PlanFlagTenantsSection({ flagKey, tenants }: Readonly<{ flagKey: string; tenants: FlagTenantInfo[] }>) { - return ( -
-
-

- Account status -

-

- - Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are - available for plan-managed flags. - -

-
-
- - - - Account ID - - - Account - - - Plan - - - Status - - - - - {tenants.map((tenant) => ( - - {tenant.tenantId} - {tenant.tenantName} - {tenant.plan} - - - {tenant.isEnabled ? t`Enabled` : t`Disabled`} - - - - ))} - -
-
- ); -} - function FlagDetailSkeleton() { return (
diff --git a/application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx b/application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx new file mode 100644 index 000000000..0eb3c405b --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx @@ -0,0 +1,154 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TextField } from "@repo/ui/components/TextField"; +import { ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { FeatureFlagInfo, FlagTenantInfo } from "./types"; + +export function PlanFlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { + return ( +
+
+
+ + Name: {flag.key} + + + Required plan: {flag.requiredPlan} + +
+ {flag.isActive ? t`Active` : t`Inactive`} +
+

+ + This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan + or higher. + +

+
+ ); +} + +export function PlanFlagTenantsSection({ tenants }: Readonly<{ tenants: FlagTenantInfo[] }>) { + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + const lowerSearch = search.toLowerCase(); + return search + ? tenants.filter( + (tenant) => tenant.tenantName.toLowerCase().includes(lowerSearch) || tenant.tenantId.includes(lowerSearch) + ) + : tenants; + }, [tenants, search]); + + const planGroups = useMemo(() => { + const groupMap = new Map(); + for (const tenant of filtered) { + const existing = groupMap.get(tenant.plan); + if (existing) { + existing.push(tenant); + } else { + groupMap.set(tenant.plan, [tenant]); + } + } + return [...groupMap.entries()].map(([plan, members]) => ({ plan, tenants: members })); + }, [filtered]); + + const isSearching = search.length > 0; + + return ( +
+
+

+ Account status +

+

+ + Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are + available for plan-managed flags. + +

+
+ setSearch(value)} + className="max-w-[20rem]" + /> + {isSearching ? ( + + ) : ( + planGroups.map((group) => ( + + )) + )} +
+ ); +} + +function PlanFlagTenantTable({ ariaLabel, tenants }: Readonly<{ ariaLabel: string; tenants: FlagTenantInfo[] }>) { + return ( + + + + + Account ID + + + Account + + + Plan + + + Status + + + + + {tenants.map((tenant) => ( + + {tenant.tenantId} + {tenant.tenantName} + {tenant.plan} + + + {tenant.isEnabled ? t`Enabled` : t`Disabled`} + + + + ))} + +
+ ); +} + +function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tenants: FlagTenantInfo[] }>) { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + {isOpen && } +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index ac4ec39f5..ca969252a 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -141,7 +141,7 @@ function TenantTable({ - + Account ID diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index db091f1cd..01aa65f22 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -21,6 +21,11 @@ msgstr "{0, plural, one {# time siden} other {# timer siden}}" msgid "{0, plural, one {# minute ago} other {# minutes ago}}" msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" +#. placeholder {0}: group.plan +#. placeholder {1}: group.tenants.length +msgid "{0} ({1})" +msgstr "" + #. placeholder {0}: formatCurrency(blended, currency) msgid "{0} blended" msgstr "{0} samlet" @@ -133,10 +138,6 @@ msgstr "" msgid "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." msgstr "" -#. placeholder {0}: getFlagName(flagKey) -msgid "Accounts for {0}" -msgstr "" - msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." From 4128caf6e1a234a8e14bf2187952ce6cb21050a6 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 5 Apr 2026 09:43:50 +0200 Subject: [PATCH 043/155] Fix control alignment, plan group order, and deactivation behavior --- .../-components/FlagInfoSection.tsx | 43 +++++++++++++------ .../-components/PlanFlagSections.tsx | 7 ++- .../Commands/SetTenantFeatureFlagInternal.cs | 1 - .../Commands/SetUserFeatureFlagInternal.cs | 1 - .../FeatureFlags/Domain/FeatureFlag.cs | 3 +- .../Tests/FeatureFlags/FeatureFlagTests.cs | 4 +- .../PlanBasedFeatureFlagServiceTests.cs | 2 +- 7 files changed, 39 insertions(+), 22 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx index 728442aa0..24be0b552 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx @@ -34,20 +34,35 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { return (
-
+
-
- {flag.isAbTestEligible && ( - - )} - {flag.isActive ? t`Active` : t`Inactive`} - -
+ {flag.isAbTestEligible ? ( +
+ + Rollout % + +
+ + {flag.isActive ? t`Active` : t`Inactive`} + +
+
+ ) : ( +
+ {flag.isActive ? t`Active` : t`Inactive`} + +
+ )}
); @@ -142,7 +157,7 @@ function RolloutPercentageInput({ return ( ) { return (
-
+
Name: {flag.key} @@ -54,7 +54,10 @@ export function PlanFlagTenantsSection({ tenants }: Readonly<{ tenants: FlagTena groupMap.set(tenant.plan, [tenant]); } } - return [...groupMap.entries()].map(([plan, members]) => ({ plan, tenants: members })); + const planOrder: Record = { Premium: 0, Standard: 1, Free: 2 }; + return [...groupMap.entries()] + .sort(([a], [b]) => (planOrder[a] ?? 99) - (planOrder[b] ?? 99)) + .map(([plan, members]) => ({ plan, tenants: members })); }, [filtered]); const isSearching = search.length > 0; diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 3786b95bb..41ded4c00 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -61,7 +61,6 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca if (tenantOverride is null) { tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId); - tenantOverride.Deactivate(now); await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); } else diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs index 965109e23..8a234eff5 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -63,7 +63,6 @@ public async Task Handle(SetUserFeatureFlagInternalCommand command, Canc if (userOverride is null) { userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId, command.UserId); - userOverride.Deactivate(now); await featureFlagRepository.AddAsync(userOverride, cancellationToken); } else diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index 2d98ae9f4..32f614759 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -66,8 +66,9 @@ public void Activate(DateTimeOffset now) public void Deactivate(DateTimeOffset now) { + if (EnabledAt is null) return; + DisabledAt = now; - EnabledAt = null; } public void SetRolloutRange(int? bucketStart, int? bucketEnd) diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index ed25271bf..15e445b02 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -85,7 +85,7 @@ public async Task DeactivateFeatureFlag_WhenActive_ShouldSetDisabledAt() "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); disabledAt.Should().NotBeNullOrEmpty(); - enabledAt.Should().BeNull("EnabledAt should be cleared on deactivation"); + enabledAt.Should().NotBeNullOrEmpty("EnabledAt should be preserved on deactivation"); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagDeactivated"); @@ -193,7 +193,7 @@ public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverrid "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", [new { flagKey, tenantId }] ); - disabledAt.Should().NotBeNullOrEmpty(); + disabledAt.Should().BeNull("newly created disabled override should not have disabled_at set when never activated"); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideRemoved"); diff --git a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs index caa472bf2..d52c69b5a 100644 --- a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs +++ b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs @@ -67,7 +67,7 @@ public async Task EvaluatePlanFlags_WhenFreePlan_ShouldDeactivatePremiumFlags() "SELECT enabled_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id = @tenantId AND user_id IS NULL", [new { tenantId = tenantId.Value }] ); - enabledAt.Should().BeNull(); + enabledAt.Should().NotBeNullOrEmpty("EnabledAt should be preserved on deactivation"); } [Fact] From 19e3a272a24336356a776185e51f2fbdef22fab3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 5 Apr 2026 12:41:26 +0200 Subject: [PATCH 044/155] Consolidate feature flag migrations into single migration --- ...02000000_AddFeatureFlagVersionToTenants.cs | 14 --- ...0_ReplaceRolloutBucketsWithVanDerCorput.cs | 87 ------------------- .../20260404100000_AddSourceToFeatureFlags.cs | 22 ----- ...0260404200000_DropRolloutBucketSequence.cs | 15 ---- ...s.cs => 20260405074500_AddFeatureFlags.cs} | 58 +++++++++++-- 5 files changed, 52 insertions(+), 144 deletions(-) delete mode 100644 application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs delete mode 100644 application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs delete mode 100644 application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs delete mode 100644 application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs rename application/account/Core/Database/Migrations/{20260310000000_AddFeatureFlags.cs => 20260405074500_AddFeatureFlags.cs} (55%) diff --git a/application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs b/application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs deleted file mode 100644 index 5f536bcbe..000000000 --- a/application/account/Core/Database/Migrations/20260402000000_AddFeatureFlagVersionToTenants.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260402000000_AddFeatureFlagVersionToTenants")] -public sealed class AddFeatureFlagVersionToTenants : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn("feature_flag_version", "tenants", "integer", nullable: false, defaultValue: 0); - } -} diff --git a/application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs b/application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs deleted file mode 100644 index 174bba5e5..000000000 --- a/application/account/Core/Database/Migrations/20260404000000_ReplaceRolloutBucketsWithVanDerCorput.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260404000000_ReplaceRolloutBucketsWithVanDerCorput")] -public sealed class ReplaceRolloutBucketsWithVanDerCorput : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - // Create a temporary function to compute van der Corput buckets in SQL - migrationBuilder.Sql( - """ - CREATE OR REPLACE FUNCTION van_der_corput_bucket(seq integer) RETURNS integer AS $$ - DECLARE - result double precision := 0; - denominator double precision := 2; - n integer := seq; - BEGIN - WHILE n > 0 LOOP - result := result + (n & 1)::double precision / denominator; - n := n >> 1; - denominator := denominator * 2; - END LOOP; - RETURN floor(result * 100)::integer; - END; - $$ LANGUAGE plpgsql IMMUTABLE; - """ - ); - - // Add rollout_bucket_sequence to tenants - migrationBuilder.AddColumn("rollout_bucket_sequence", "tenants", "integer", nullable: true); - - migrationBuilder.Sql( - """ - WITH numbered AS ( - SELECT id, row_number() OVER (ORDER BY created_at, id) - 1 AS seq - FROM tenants - ) - UPDATE tenants SET rollout_bucket_sequence = numbered.seq - FROM numbered WHERE tenants.id = numbered.id - """ - ); - - migrationBuilder.Sql("ALTER TABLE tenants ALTER COLUMN rollout_bucket_sequence SET NOT NULL"); - - // Recompute rollout_bucket using van der Corput - migrationBuilder.Sql("UPDATE tenants SET rollout_bucket = van_der_corput_bucket(rollout_bucket_sequence)"); - - // Add rollout_bucket_sequence to users - migrationBuilder.AddColumn("rollout_bucket_sequence", "users", "integer", nullable: true); - - migrationBuilder.Sql( - """ - WITH numbered AS ( - SELECT id, row_number() OVER (ORDER BY created_at, id) - 1 AS seq - FROM users - ) - UPDATE users SET rollout_bucket_sequence = numbered.seq - FROM numbered WHERE users.id = numbered.id - """ - ); - - migrationBuilder.Sql("ALTER TABLE users ALTER COLUMN rollout_bucket_sequence SET NOT NULL"); - - // Recompute rollout_bucket using van der Corput - migrationBuilder.Sql("UPDATE users SET rollout_bucket = van_der_corput_bucket(rollout_bucket_sequence)"); - - // Drop the temporary function - migrationBuilder.Sql("DROP FUNCTION van_der_corput_bucket(integer)"); - - // Update bucket_range constraint from 1-100 to 0-99 - migrationBuilder.Sql("ALTER TABLE feature_flags DROP CONSTRAINT ck_feature_flags_bucket_range"); - - migrationBuilder.Sql( - """ - ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_bucket_range - CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 0 AND 99 AND bucket_end BETWEEN 0 AND 99))) - """ - ); - - // Update any existing rollout ranges that used bucket 100 to use 99 - migrationBuilder.Sql("UPDATE feature_flags SET bucket_end = 99 WHERE bucket_end = 100"); - migrationBuilder.Sql("UPDATE feature_flags SET bucket_start = 99 WHERE bucket_start = 100"); - } -} diff --git a/application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs b/application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs deleted file mode 100644 index 2153c2613..000000000 --- a/application/account/Core/Database/Migrations/20260404100000_AddSourceToFeatureFlags.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260404100000_AddSourceToFeatureFlags")] -public sealed class AddSourceToFeatureFlags : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn("source", "feature_flags", "text", nullable: false, defaultValue: "Manual"); - - migrationBuilder.Sql( - """ - UPDATE feature_flags - SET source = 'AbRollout' - WHERE bucket_start IS NOT NULL AND bucket_end IS NOT NULL AND tenant_id IS NULL AND user_id IS NULL - """ - ); - } -} diff --git a/application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs b/application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs deleted file mode 100644 index e73778602..000000000 --- a/application/account/Core/Database/Migrations/20260404200000_DropRolloutBucketSequence.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260404200000_DropRolloutBucketSequence")] -public sealed class DropRolloutBucketSequence : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn("rollout_bucket_sequence", "tenants"); - migrationBuilder.DropColumn("rollout_bucket_sequence", "users"); - } -} diff --git a/application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs b/application/account/Core/Database/Migrations/20260405074500_AddFeatureFlags.cs similarity index 55% rename from application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs rename to application/account/Core/Database/Migrations/20260405074500_AddFeatureFlags.cs index 897386d19..1dd3c4fa7 100644 --- a/application/account/Core/Database/Migrations/20260310000000_AddFeatureFlags.cs +++ b/application/account/Core/Database/Migrations/20260405074500_AddFeatureFlags.cs @@ -4,7 +4,7 @@ namespace Account.Database.Migrations; [DbContext(typeof(AccountDbContext))] -[Migration("20260310000000_AddFeatureFlags")] +[Migration("20260405074500_AddFeatureFlags")] public sealed class AddFeatureFlags : Migration { protected override void Up(MigrationBuilder migrationBuilder) @@ -24,7 +24,8 @@ protected override void Up(MigrationBuilder migrationBuilder) bucket_start = table.Column("integer", nullable: true), bucket_end = table.Column("integer", nullable: true), configurable_by_tenant = table.Column("boolean", nullable: false, defaultValue: false), - configurable_by_user = table.Column("boolean", nullable: false, defaultValue: false) + configurable_by_user = table.Column("boolean", nullable: false, defaultValue: false), + source = table.Column("text", nullable: false, defaultValue: "Manual") }, constraints: table => { table.PrimaryKey("pk_feature_flags", x => x.id); } ); @@ -40,16 +41,61 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.Sql( """ ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_bucket_range - CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 1 AND 100 AND bucket_end BETWEEN 1 AND 100))) + CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 0 AND 99 AND bucket_end BETWEEN 0 AND 99))) """ ); + migrationBuilder.AddColumn("feature_flag_version", "tenants", "integer", nullable: false, defaultValue: 0); + + // Add rollout_bucket to tenants and users, computed via van der Corput sequence migrationBuilder.AddColumn("rollout_bucket", "tenants", "smallint", nullable: true); - migrationBuilder.Sql("UPDATE tenants SET rollout_bucket = floor(random() * 100 + 1)::smallint WHERE rollout_bucket IS NULL"); + migrationBuilder.AddColumn("rollout_bucket", "users", "smallint", nullable: true); + + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION van_der_corput_bucket(seq integer) RETURNS integer AS $$ + DECLARE + result double precision := 0; + denominator double precision := 2; + n integer := seq; + BEGIN + WHILE n > 0 LOOP + result := result + (n & 1)::double precision / denominator; + n := n >> 1; + denominator := denominator * 2; + END LOOP; + RETURN floor(result * 100)::integer; + END; + $$ LANGUAGE plpgsql IMMUTABLE; + """ + ); + + migrationBuilder.Sql( + """ + WITH numbered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) - 1 AS seq + FROM tenants + ) + UPDATE tenants SET rollout_bucket = van_der_corput_bucket(numbered.seq::integer) + FROM numbered WHERE tenants.id = numbered.id + """ + ); + migrationBuilder.Sql("ALTER TABLE tenants ALTER COLUMN rollout_bucket SET NOT NULL"); - migrationBuilder.AddColumn("rollout_bucket", "users", "smallint", nullable: true); - migrationBuilder.Sql("UPDATE users SET rollout_bucket = floor(random() * 100 + 1)::smallint WHERE rollout_bucket IS NULL"); + migrationBuilder.Sql( + """ + WITH numbered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) - 1 AS seq + FROM users + ) + UPDATE users SET rollout_bucket = van_der_corput_bucket(numbered.seq::integer) + FROM numbered WHERE users.id = numbered.id + """ + ); + migrationBuilder.Sql("ALTER TABLE users ALTER COLUMN rollout_bucket SET NOT NULL"); + + migrationBuilder.Sql("DROP FUNCTION van_der_corput_bucket(integer)"); } } From 6f0c07bf80233807d8fb563b76a510505272320d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 5 Apr 2026 13:50:59 +0200 Subject: [PATCH 045/155] Rename abbreviated names to descriptive names and refactor services to shared commands --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 12 +- .../Api/Endpoints/FeatureFlagEndpoints.cs | 12 +- .../routes/feature-flags/$flagKey.tsx | 87 +++++++------ ...Section.tsx => FeatureFlagInfoSection.tsx} | 57 +++++---- ...ctions.tsx => PlanFeatureFlagSections.tsx} | 27 +++-- .../-components/TenantOverrideRow.tsx | 24 ++-- .../-components/TenantOverridesSection.tsx | 78 ++++++------ .../-components/UserOverrideRow.tsx | 26 ++-- .../-components/UserOverridesSection.tsx | 73 ++++++----- .../feature-flags/-components/flagLabels.ts | 14 +-- .../-components/rolloutBucket.ts | 42 +++++-- .../routes/feature-flags/-components/types.ts | 12 +- .../BackOffice/routes/feature-flags/index.tsx | 56 ++++----- .../shared/translations/locale/da-DK.po | 34 ++++-- application/account/Core/Configuration.cs | 6 +- .../20260310000100_SeedFeatureFlags.cs | 16 +-- ...0260404100100_SeedPlanBasedFeatureFlags.cs | 18 +-- .../Commands/ActivateFeatureFlag.cs | 12 +- .../Commands/DeactivateFeatureFlag.cs | 12 +- .../RemoveTenantFeatureFlagOverride.cs | 6 +- .../Commands/RemoveUserFeatureFlagOverride.cs | 6 +- .../SetFeatureFlagRolloutPercentage.cs | 32 ++--- .../Commands/SetTenantFeatureFlagInternal.cs | 6 +- .../Commands/SetTenantFeatureFlagOwner.cs | 6 +- .../Commands/SetUserFeatureFlag.cs | 6 +- .../Commands/SetUserFeatureFlagInternal.cs | 6 +- .../FeatureFlags/Domain/FeatureFlag.cs | 16 +-- .../Queries/GetFeatureFlagTenants.cs | 76 ++++++++++++ ...GetFlagUsers.cs => GetFeatureFlagUsers.cs} | 38 +++--- .../FeatureFlags/Queries/GetFeatureFlags.cs | 42 +++---- .../FeatureFlags/Queries/GetFlagTenants.cs | 76 ------------ .../FeatureFlagEvaluator.cs} | 30 ++--- .../PlanBasedFeatureFlagEvaluator.cs} | 24 ++-- .../Shared/ProcessPendingStripeEvents.cs | 6 +- .../Core/Features/Tenants/Domain/Tenant.cs | 2 +- .../Core/Features/Users/Domain/User.cs | 2 +- .../Features/Users/Shared/UserInfoFactory.cs | 8 +- ...eTests.cs => FeatureFlagEvaluatorTests.cs} | 16 +-- .../Tests/FeatureFlags/FeatureFlagTests.cs | 114 +++++++++--------- ... => PlanBasedFeatureFlagEvaluatorTests.cs} | 10 +- .../settings/-components/FeaturesSection.tsx | 4 +- .../-components/BetaFeaturesSection.tsx | 4 +- .../FeatureFlags/FeatureFlagDefinition.cs | 20 ++- .../SharedKernel/FeatureFlags/FeatureFlags.cs | 87 +++++++------ .../FeatureFlags/RolloutBucketHasher.cs | 10 +- ...ApplicationInsightsTelemetryInitializer.cs | 8 +- .../Telemetry/OpenTelemetryEnricher.cs | 8 +- 47 files changed, 679 insertions(+), 608 deletions(-) rename application/account/BackOffice/routes/feature-flags/-components/{FlagInfoSection.tsx => FeatureFlagInfoSection.tsx} (68%) rename application/account/BackOffice/routes/feature-flags/-components/{PlanFlagSections.tsx => PlanFeatureFlagSections.tsx} (82%) create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs rename application/account/Core/Features/FeatureFlags/Queries/{GetFlagUsers.cs => GetFeatureFlagUsers.cs} (56%) delete mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs rename application/account/Core/Features/FeatureFlags/{FeatureFlagEvaluationService.cs => Shared/FeatureFlagEvaluator.cs} (73%) rename application/account/Core/Features/FeatureFlags/{PlanBasedFeatureFlagService.cs => Shared/PlanBasedFeatureFlagEvaluator.cs} (66%) rename application/account/Tests/FeatureFlags/{FeatureFlagEvaluationServiceTests.cs => FeatureFlagEvaluatorTests.cs} (95%) rename application/account/Tests/FeatureFlags/{PlanBasedFeatureFlagServiceTests.cs => PlanBasedFeatureFlagEvaluatorTests.cs} (95%) diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 07cbdbdf9..862147eb6 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -27,9 +27,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new GetFeatureFlagsQuery()) ).Produces(); - group.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) - => await mediator.Send(new GetFlagTenantsQuery { FlagKey = flagKey }) - ).Produces(); + group.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) + => await mediator.Send(new GetFeatureFlagTenantsQuery { FlagKey = flagKey }) + ).Produces(); group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) @@ -51,9 +51,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - group.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) - => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey, Search = search }) - ).Produces(); + group.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) + => await mediator.Send(new GetFeatureFlagUsersQuery { FlagKey = flagKey, Search = search }) + ).Produces(); group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 072fa63e9..ed9f0f823 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -17,9 +17,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new GetFeatureFlagsQuery()) ).Produces(); - internalGroup.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) - => await mediator.Send(new GetFlagTenantsQuery { FlagKey = flagKey }) - ).Produces(); + internalGroup.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) + => await mediator.Send(new GetFeatureFlagTenantsQuery { FlagKey = flagKey }) + ).Produces(); internalGroup.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) @@ -41,9 +41,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) - => await mediator.Send(new GetFlagUsersQuery { FlagKey = flagKey, Search = search }) - ).Produces(); + internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) + => await mediator.Send(new GetFeatureFlagUsersQuery { FlagKey = flagKey, Search = search }) + ).Produces(); internalGroup.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 8c2b7b9a6..7b716a7be 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -8,44 +8,47 @@ import { ArrowLeftIcon } from "lucide-react"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; import { api } from "@/shared/lib/api/client"; -import type { GetFeatureFlagsResponse, GetFlagTenantsResponse } from "./-components/types"; +import type { GetFeatureFlagsResponse, GetFeatureFlagTenantsResponse } from "./-components/types"; -import { FlagInfoSection } from "./-components/FlagInfoSection"; -import { getFlagDescription, getFlagName } from "./-components/flagLabels"; -import { PlanFlagInfoSection, PlanFlagTenantsSection } from "./-components/PlanFlagSections"; +import { FeatureFlagInfoSection } from "./-components/FeatureFlagInfoSection"; +import { getFeatureFlagDescription, getFeatureFlagName } from "./-components/flagLabels"; +import { PlanFeatureFlagInfoSection, PlanFeatureFlagTenantsSection } from "./-components/PlanFeatureFlagSections"; import { ScopeIcon } from "./-components/ScopeIcon"; import { TenantOverridesSection } from "./-components/TenantOverridesSection"; import { UserOverridesSection } from "./-components/UserOverridesSection"; export const Route = createFileRoute("/feature-flags/$flagKey")({ staticData: { trackingTitle: "Feature flag detail" }, - component: FlagDetailPage + component: FeatureFlagDetailPage }); -export default function FlagDetailPage() { +export default function FeatureFlagDetailPage() { const { flagKey } = Route.useParams(); - const { data: flagsData, isLoading: isLoadingFlags } = api.useQuery("get", "/api/back-office/feature-flags") as { + const { data: featureFlagsData, isLoading: isLoadingFeatureFlags } = api.useQuery( + "get", + "/api/back-office/feature-flags" + ) as { data: GetFeatureFlagsResponse | undefined; isLoading: boolean; }; - const flag = flagsData?.flags?.find((f) => f.key === flagKey); + const featureFlag = featureFlagsData?.flags?.find((f) => f.key === flagKey); const { data: tenantsData, isLoading: isLoadingTenants } = api.useQuery( "get", "/api/back-office/feature-flags/{flagKey}/tenants", { params: { path: { flagKey } } }, - { enabled: flag?.scope === "Tenant" } + { enabled: featureFlag?.scope === "Tenant" } ) as { - data: GetFlagTenantsResponse | undefined; + data: GetFeatureFlagTenantsResponse | undefined; isLoading: boolean; }; - const isPlanFlag = flag?.requiredPlan != null; - const isLoading = isLoadingFlags || (flag?.scope === "Tenant" && isLoadingTenants); - const flagName = flag ? getFlagName(flag.key) : flagKey; - const description = flag ? getFlagDescription(flag.key) || flag.description : ""; + const isPlanFeatureFlag = featureFlag?.requiredPlan != null; + const isLoading = isLoadingFeatureFlags || (featureFlag?.scope === "Tenant" && isLoadingTenants); + const featureFlagName = featureFlag ? getFeatureFlagName(featureFlag.key) : flagKey; + const description = featureFlag ? getFeatureFlagDescription(featureFlag.key) || featureFlag.description : ""; return ( @@ -53,51 +56,55 @@ export default function FlagDetailPage() { - {flag && } - {flagName} + {featureFlag && } + {featureFlagName}
} - subtitle={flag ? description : undefined} + subtitle={featureFlag ? description : undefined} > {isLoading ? ( - - ) : flag ? ( + + ) : featureFlag ? (
- {isPlanFlag ? : } - {flag.scope === "Tenant" && !isPlanFlag && ( + {isPlanFeatureFlag ? ( + + ) : ( + + )} + {featureFlag.scope === "Tenant" && !isPlanFeatureFlag && ( )} - {flag.scope === "Tenant" && isPlanFlag && ( - + {featureFlag.scope === "Tenant" && isPlanFeatureFlag && ( + )} - {flag.scope === "User" && ( + {featureFlag.scope === "User" && ( )}
@@ -108,7 +115,7 @@ export default function FlagDetailPage() { ); } -function FlagDetailSkeleton() { +function FeatureFlagDetailSkeleton() { return (
diff --git a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx similarity index 68% rename from application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx rename to application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx index 24be0b552..72d29512a 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx @@ -12,10 +12,10 @@ import { api } from "@/shared/lib/api/client"; import type { FeatureFlagInfo } from "./types"; -import { getFlagName } from "./flagLabels"; -import { formatBucketRange } from "./rolloutBucket"; +import { getFeatureFlagName } from "./flagLabels"; +import { formatRolloutBucketRange } from "./rolloutBucket"; -export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { +export function FeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: FeatureFlagInfo }>) { const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); const isPending = activateMutation.isPending || deactivateMutation.isPending; @@ -23,7 +23,7 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { const handleToggle = (checked: boolean) => { const mutation = checked ? activateMutation : deactivateMutation; mutation.mutate( - { params: { path: { flagKey: flag.key } } }, + { params: { path: { flagKey: featureFlag.key } } }, { onSuccess: () => { toast.success(checked ? t`Feature flag activated` : t`Feature flag deactivated`); @@ -35,31 +35,35 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { return (
- - {flag.isAbTestEligible ? ( + + {featureFlag.isAbTestEligible ? (
Rollout %
- - {flag.isActive ? t`Active` : t`Inactive`} + + + {featureFlag.isActive ? t`Active` : t`Inactive`} +
) : (
- {flag.isActive ? t`Active` : t`Inactive`} + + {featureFlag.isActive ? t`Active` : t`Inactive`} +
)} @@ -68,31 +72,34 @@ export function FlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { ); } -function FlagMetadata({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { +function FeatureFlagMetadata({ featureFlag }: Readonly<{ featureFlag: FeatureFlagInfo }>) { const enabledLine = - flag.enabledAt && flag.disabledAt - ? t`Enabled period: ${formatTimestamp(flag.enabledAt)} - ${formatTimestamp(flag.disabledAt)}` - : flag.enabledAt - ? t`Enabled: ${formatTimestamp(flag.enabledAt)}` + featureFlag.enabledAt && featureFlag.disabledAt + ? t`Enabled period: ${formatTimestamp(featureFlag.enabledAt)} - ${formatTimestamp(featureFlag.disabledAt)}` + : featureFlag.enabledAt + ? t`Enabled: ${formatTimestamp(featureFlag.enabledAt)}` : null; - const bucketLine = - flag.isAbTestEligible && flag.bucketStart != null && flag.bucketEnd != null && flag.rolloutPercentage != null - ? formatBucketRange(flag.bucketStart, flag.bucketEnd, flag.rolloutPercentage) + const rolloutBucketLine = + featureFlag.isAbTestEligible && + featureFlag.bucketStart != null && + featureFlag.bucketEnd != null && + featureFlag.rolloutPercentage != null + ? formatRolloutBucketRange(featureFlag.bucketStart, featureFlag.bucketEnd, featureFlag.rolloutPercentage) : null; return (
- Name: {flag.key} + Name: {featureFlag.key} {enabledLine && {enabledLine}} - {bucketLine && ( + {rolloutBucketLine && ( - {bucketLine} + {rolloutBucketLine} - + diff --git a/application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx b/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx similarity index 82% rename from application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx rename to application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx index 60f1ac269..be2941abc 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/PlanFlagSections.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx @@ -6,21 +6,23 @@ import { TextField } from "@repo/ui/components/TextField"; import { ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; -import type { FeatureFlagInfo, FlagTenantInfo } from "./types"; +import type { FeatureFlagInfo, FeatureFlagTenantInfo } from "./types"; -export function PlanFlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo }>) { +export function PlanFeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: FeatureFlagInfo }>) { return (
- Name: {flag.key} + Name: {featureFlag.key} - Required plan: {flag.requiredPlan} + Required plan: {featureFlag.requiredPlan}
- {flag.isActive ? t`Active` : t`Inactive`} + + {featureFlag.isActive ? t`Active` : t`Inactive`} +

@@ -32,7 +34,7 @@ export function PlanFlagInfoSection({ flag }: Readonly<{ flag: FeatureFlagInfo } ); } -export function PlanFlagTenantsSection({ tenants }: Readonly<{ tenants: FlagTenantInfo[] }>) { +export function PlanFeatureFlagTenantsSection({ tenants }: Readonly<{ tenants: FeatureFlagTenantInfo[] }>) { const [search, setSearch] = useState(""); const filtered = useMemo(() => { @@ -45,7 +47,7 @@ export function PlanFlagTenantsSection({ tenants }: Readonly<{ tenants: FlagTena }, [tenants, search]); const planGroups = useMemo(() => { - const groupMap = new Map(); + const groupMap = new Map(); for (const tenant of filtered) { const existing = groupMap.get(tenant.plan); if (existing) { @@ -83,7 +85,7 @@ export function PlanFlagTenantsSection({ tenants }: Readonly<{ tenants: FlagTena className="max-w-[20rem]" /> {isSearching ? ( - + ) : ( planGroups.map((group) => ( ) { +function PlanFeatureFlagTenantTable({ + ariaLabel, + tenants +}: Readonly<{ ariaLabel: string; tenants: FeatureFlagTenantInfo[] }>) { return (

@@ -134,7 +139,7 @@ function PlanFlagTenantTable({ ariaLabel, tenants }: Readonly<{ ariaLabel: strin ); } -function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tenants: FlagTenantInfo[] }>) { +function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tenants: FeatureFlagTenantInfo[] }>) { const [isOpen, setIsOpen] = useState(true); return ( @@ -151,7 +156,7 @@ function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tena />

{label}

- {isOpen && } + {isOpen && } ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index b406af398..19c2dc7b2 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -10,7 +10,7 @@ import { toast } from "sonner"; import { api, queryClient } from "@/shared/lib/api/client"; -import type { FlagTenantInfo } from "./types"; +import type { FeatureFlagTenantInfo } from "./types"; function getSourceLabel(source: string): string { switch (source) { @@ -29,16 +29,16 @@ function getSourceLabel(source: string): string { export function TenantOverrideRow({ flagKey, - flagDescription, + featureFlagDescription, tenant, - showBucket, - isFlagActive + showRolloutBucket, + isFeatureFlagActive }: Readonly<{ flagKey: string; - flagDescription: string; - tenant: FlagTenantInfo; - showBucket: boolean; - isFlagActive: boolean; + featureFlagDescription: string; + tenant: FeatureFlagTenantInfo; + showRolloutBucket: boolean; + isFeatureFlagActive: boolean; }>) { const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); @@ -63,8 +63,8 @@ export function TenantOverrideRow({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] }); const message = checked - ? t`${flagDescription} enabled for ${tenant.tenantName}` - : t`${flagDescription} disabled for ${tenant.tenantName}`; + ? t`${featureFlagDescription} enabled for ${tenant.tenantName}` + : t`${featureFlagDescription} disabled for ${tenant.tenantName}`; toast.success(message); }, onError: () => { @@ -100,7 +100,7 @@ export function TenantOverrideRow({ {getSourceLabel(tenant.source)} - {showBucket && ( + {showRolloutBucket && ( {tenant.rolloutBucket} )} @@ -130,7 +130,7 @@ export function TenantOverrideRow({ checked={optimisticEnabled} onCheckedChange={handleToggle} disabled={isPending} - className={!isFlagActive && optimisticEnabled ? "opacity-50" : ""} + className={!isFeatureFlagActive && optimisticEnabled ? "opacity-50" : ""} aria-label={t`Override for ${tenant.tenantName}`} /> diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index ca969252a..43c09c513 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -5,26 +5,26 @@ import { TextField } from "@repo/ui/components/TextField"; import { ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; -import type { BucketRange } from "./rolloutBucket"; -import type { FlagTenantInfo } from "./types"; +import type { RolloutBucketRange } from "./rolloutBucket"; +import type { FeatureFlagTenantInfo } from "./types"; -import { sortBySourceThenBucket } from "./rolloutBucket"; +import { sortBySourceThenRolloutBucket } from "./rolloutBucket"; import { TenantOverrideRow } from "./TenantOverrideRow"; export function TenantOverridesSection({ flagKey, - flagDescription, + featureFlagDescription, tenants, - showBucket, - bucketRange, - isFlagActive + showRolloutBucket, + rolloutBucketRange, + isFeatureFlagActive }: Readonly<{ flagKey: string; - flagDescription: string; - tenants: FlagTenantInfo[]; - showBucket: boolean; - bucketRange: BucketRange | null; - isFlagActive: boolean; + featureFlagDescription: string; + tenants: FeatureFlagTenantInfo[]; + showRolloutBucket: boolean; + rolloutBucketRange: RolloutBucketRange | null; + isFeatureFlagActive: boolean; }>) { const [search, setSearch] = useState(""); @@ -39,26 +39,26 @@ export function TenantOverridesSection({ const enabledTenants = useMemo( () => - sortBySourceThenBucket( + sortBySourceThenRolloutBucket( filtered.filter((t) => t.isEnabled), (t) => t.source, (t) => t.rolloutBucket, "enabled", - bucketRange + rolloutBucketRange ), - [filtered, bucketRange] + [filtered, rolloutBucketRange] ); const disabledTenants = useMemo( () => - sortBySourceThenBucket( + sortBySourceThenRolloutBucket( filtered.filter((t) => !t.isEnabled), (t) => t.source, (t) => t.rolloutBucket, "disabled", - bucketRange + rolloutBucketRange ), - [filtered, bucketRange] + [filtered, rolloutBucketRange] ); const isSearching = search.length > 0; @@ -70,7 +70,7 @@ export function TenantOverridesSection({ Account status

- {showBucket ? ( + {showRolloutBucket ? ( Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts. @@ -92,9 +92,9 @@ export function TenantOverridesSection({ ariaLabel={t`Search results`} tenants={[...enabledTenants, ...disabledTenants]} flagKey={flagKey} - flagDescription={flagDescription} - showBucket={showBucket} - isFlagActive={isFlagActive} + featureFlagDescription={featureFlagDescription} + showRolloutBucket={showRolloutBucket} + isFeatureFlagActive={isFeatureFlagActive} /> ) : ( <> @@ -102,17 +102,17 @@ export function TenantOverridesSection({ label={t`Enabled (${enabledTenants.length})`} tenants={enabledTenants} flagKey={flagKey} - flagDescription={flagDescription} - showBucket={showBucket} - isFlagActive={isFlagActive} + featureFlagDescription={featureFlagDescription} + showRolloutBucket={showRolloutBucket} + isFeatureFlagActive={isFeatureFlagActive} /> )} @@ -122,20 +122,20 @@ export function TenantOverridesSection({ interface TenantTableProps { ariaLabel: string; - tenants: FlagTenantInfo[]; + tenants: FeatureFlagTenantInfo[]; flagKey: string; - flagDescription: string; - showBucket: boolean; - isFlagActive: boolean; + featureFlagDescription: string; + showRolloutBucket: boolean; + isFeatureFlagActive: boolean; } function TenantTable({ ariaLabel, tenants, flagKey, - flagDescription, - showBucket, - isFlagActive + featureFlagDescription, + showRolloutBucket, + isFeatureFlagActive }: Readonly) { return (

@@ -153,7 +153,7 @@ function TenantTable({ Source - {showBucket && ( + {showRolloutBucket && ( Bucket @@ -168,10 +168,10 @@ function TenantTable({ ))} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index 216b5e252..ae8620444 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -11,7 +11,7 @@ import { toast } from "sonner"; import { apiClient, queryClient } from "@/shared/lib/api/client"; -import type { FlagUserInfo } from "./types"; +import type { FeatureFlagUserInfo } from "./types"; function getSourceLabel(source: string): string { switch (source) { @@ -30,16 +30,16 @@ function getSourceLabel(source: string): string { export function UserOverrideRow({ flagKey, - flagDescription, + featureFlagDescription, user, - showBucket, - isFlagActive + showRolloutBucket, + isFeatureFlagActive }: Readonly<{ flagKey: string; - flagDescription: string; - user: FlagUserInfo; - showBucket: boolean; - isFlagActive: boolean; + featureFlagDescription: string; + user: FeatureFlagUserInfo; + showRolloutBucket: boolean; + isFeatureFlagActive: boolean; }>) { const [optimisticEnabled, setOptimisticEnabled] = useState(user.isEnabled); @@ -80,8 +80,8 @@ export function UserOverrideRow({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] }); const message = checked - ? t`${flagDescription} enabled for ${user.email}` - : t`${flagDescription} disabled for ${user.email}`; + ? t`${featureFlagDescription} enabled for ${user.email}` + : t`${featureFlagDescription} disabled for ${user.email}`; toast.success(message); }, onError: () => { @@ -111,7 +111,9 @@ export function UserOverrideRow({ {getSourceLabel(user.source)} - {showBucket && {user.rolloutBucket}} + {showRolloutBucket && ( + {user.rolloutBucket} + )}
{user.source === "manual_override" && ( @@ -139,7 +141,7 @@ export function UserOverrideRow({ checked={optimisticEnabled} onCheckedChange={handleToggle} disabled={isPending} - className={!isFlagActive && optimisticEnabled ? "opacity-50" : ""} + className={!isFeatureFlagActive && optimisticEnabled ? "opacity-50" : ""} aria-label={t`Override for ${user.email}`} />
diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index c24b4d8f6..4c55519fc 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -8,25 +8,25 @@ import { useEffect, useMemo, useState } from "react"; import { apiClient } from "@/shared/lib/api/client"; -import type { BucketRange } from "./rolloutBucket"; -import type { FlagUserInfo, GetFlagUsersResponse } from "./types"; +import type { RolloutBucketRange } from "./rolloutBucket"; +import type { FeatureFlagUserInfo, GetFeatureFlagUsersResponse } from "./types"; -import { sortBySourceThenBucket } from "./rolloutBucket"; +import { sortBySourceThenRolloutBucket } from "./rolloutBucket"; import { UserEmptyState } from "./UserEmptyState"; import { UserOverrideRow } from "./UserOverrideRow"; export function UserOverridesSection({ flagKey, - flagDescription, - showBucket, - bucketRange, - isFlagActive + featureFlagDescription, + showRolloutBucket, + rolloutBucketRange, + isFeatureFlagActive }: Readonly<{ flagKey: string; - flagDescription: string; - showBucket: boolean; - bucketRange: BucketRange | null; - isFlagActive: boolean; + featureFlagDescription: string; + showRolloutBucket: boolean; + rolloutBucketRange: RolloutBucketRange | null; + isFeatureFlagActive: boolean; }>) { const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); @@ -43,7 +43,7 @@ export function UserOverridesSection({ const { data } = await apiClient.GET("/api/back-office/feature-flags/{flagKey}/users" as any, { params: { path: { flagKey }, query: { search: debouncedSearch } } }); - return data as GetFlagUsersResponse | undefined; + return data as GetFeatureFlagUsersResponse | undefined; }, enabled: debouncedSearch.length > 0 }); @@ -53,22 +53,22 @@ export function UserOverridesSection({ const enabled = all.filter((u) => u.isEnabled); const disabled = all.filter((u) => !u.isEnabled); return { - enabledUsers: sortBySourceThenBucket( + enabledUsers: sortBySourceThenRolloutBucket( enabled, (u) => u.source, (u) => u.rolloutBucket, "enabled", - bucketRange + rolloutBucketRange ), - disabledUsers: sortBySourceThenBucket( + disabledUsers: sortBySourceThenRolloutBucket( disabled, (u) => u.source, (u) => u.rolloutBucket, "disabled", - bucketRange + rolloutBucketRange ) }; - }, [usersData?.users, bucketRange]); + }, [usersData?.users, rolloutBucketRange]); const hasSearched = debouncedSearch.length > 0; @@ -79,7 +79,7 @@ export function UserOverridesSection({ User status

- {showBucket ? ( + {showRolloutBucket ? ( Users are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific users. @@ -106,17 +106,17 @@ export function UserOverridesSection({ label={t`Enabled (${enabledUsers.length})`} users={enabledUsers} flagKey={flagKey} - flagDescription={flagDescription} - showBucket={showBucket} - isFlagActive={isFlagActive} + featureFlagDescription={featureFlagDescription} + showRolloutBucket={showRolloutBucket} + isFeatureFlagActive={isFeatureFlagActive} /> ) : ( @@ -128,14 +128,21 @@ export function UserOverridesSection({ interface UserTableProps { ariaLabel: string; - users: FlagUserInfo[]; + users: FeatureFlagUserInfo[]; flagKey: string; - flagDescription: string; - showBucket: boolean; - isFlagActive: boolean; + featureFlagDescription: string; + showRolloutBucket: boolean; + isFeatureFlagActive: boolean; } -function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket, isFlagActive }: Readonly) { +function UserTable({ + ariaLabel, + users, + flagKey, + featureFlagDescription, + showRolloutBucket, + isFeatureFlagActive +}: Readonly) { return (

@@ -149,7 +156,7 @@ function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket, isF Source - {showBucket && ( + {showRolloutBucket && ( Bucket @@ -164,10 +171,10 @@ function UserTable({ ariaLabel, users, flagKey, flagDescription, showBucket, isF ))} diff --git a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts index 5f19fb27c..230fb4109 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts @@ -1,11 +1,11 @@ import { t } from "@lingui/core/macro"; -interface FlagLabel { +interface FeatureFlagLabel { name: string; description: string; } -function getKnownFlagLabels(): Record { +function getKnownFeatureFlagLabels(): Record { return { "google-oauth": { name: t`Google OAuth`, @@ -38,15 +38,15 @@ function getKnownFlagLabels(): Record { }; } -function formatFlagKey(flagKey: string): string { +function formatFeatureFlagKey(flagKey: string): string { const formatted = flagKey.replace(/-/g, " "); return formatted.charAt(0).toUpperCase() + formatted.slice(1); } -export function getFlagName(flagKey: string): string { - return getKnownFlagLabels()[flagKey]?.name ?? formatFlagKey(flagKey); +export function getFeatureFlagName(flagKey: string): string { + return getKnownFeatureFlagLabels()[flagKey]?.name ?? formatFeatureFlagKey(flagKey); } -export function getFlagDescription(flagKey: string): string { - return getKnownFlagLabels()[flagKey]?.description ?? ""; +export function getFeatureFlagDescription(flagKey: string): string { + return getKnownFeatureFlagLabels()[flagKey]?.description ?? ""; } diff --git a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts index 3b43701b5..ec03e9d29 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts @@ -1,36 +1,52 @@ import { t } from "@lingui/core/macro"; -const BUCKET_MAX = 100; +const ROLLOUT_BUCKET_MAX = 100; -export function formatBucketRange(bucketStart: number, bucketEnd: number, rolloutPercentage: number): string { - if (bucketStart <= bucketEnd) { - return t`Rollout buckets: ${bucketStart}-${bucketEnd} (${rolloutPercentage}%)`; +export function formatRolloutBucketRange( + rolloutBucketStart: number, + rolloutBucketEnd: number, + rolloutPercentage: number +): string { + if (rolloutBucketStart <= rolloutBucketEnd) { + return t`Rollout buckets: ${rolloutBucketStart}-${rolloutBucketEnd} (${rolloutPercentage}%)`; } - return t`Rollout buckets: ${bucketStart}-99 and 0-${bucketEnd} (${rolloutPercentage}%)`; + return t`Rollout buckets: ${rolloutBucketStart}-99 and 0-${rolloutBucketEnd} (${rolloutPercentage}%)`; } -export interface BucketRange { +export interface RolloutBucketRange { bucketStart: number; bucketEnd: number; } -function bucketSortOrder(bucket: number, group: "enabled" | "disabled", range: BucketRange): number { +function rolloutBucketSortOrder( + rolloutBucket: number, + group: "enabled" | "disabled", + range: RolloutBucketRange +): number { const ref = group === "disabled" ? range.bucketEnd : range.bucketStart; - return (bucket - ref + BUCKET_MAX) % BUCKET_MAX; + return (rolloutBucket - ref + ROLLOUT_BUCKET_MAX) % ROLLOUT_BUCKET_MAX; } -export function sortBySourceThenBucket( +export function sortBySourceThenRolloutBucket( items: T[], getSource: (item: T) => string, - getBucket: (item: T) => number, + getRolloutBucket: (item: T) => number, group: "enabled" | "disabled", - bucketRange: BucketRange | null + rolloutBucketRange: RolloutBucketRange | null ): T[] { return [...items].sort((a, b) => { const aOrder = - getSource(a) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getBucket(a), group, bucketRange) : 0; + getSource(a) === "manual_override" + ? -1 + : rolloutBucketRange + ? rolloutBucketSortOrder(getRolloutBucket(a), group, rolloutBucketRange) + : 0; const bOrder = - getSource(b) === "manual_override" ? -1 : bucketRange ? bucketSortOrder(getBucket(b), group, bucketRange) : 0; + getSource(b) === "manual_override" + ? -1 + : rolloutBucketRange + ? rolloutBucketSortOrder(getRolloutBucket(b), group, rolloutBucketRange) + : 0; return aOrder - bOrder; }); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 08c1c48df..7caede917 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -22,7 +22,7 @@ export interface GetFeatureFlagsResponse { flags: FeatureFlagInfo[]; } -export interface FlagTenantInfo { +export interface FeatureFlagTenantInfo { tenantId: string; tenantName: string; plan: string; @@ -31,11 +31,11 @@ export interface FlagTenantInfo { rolloutBucket: number; } -export interface GetFlagTenantsResponse { - tenants: FlagTenantInfo[]; +export interface GetFeatureFlagTenantsResponse { + tenants: FeatureFlagTenantInfo[]; } -export interface FlagUserInfo { +export interface FeatureFlagUserInfo { userId: string; tenantId: string; email: string; @@ -45,6 +45,6 @@ export interface FlagUserInfo { rolloutBucket: number; } -export interface GetFlagUsersResponse { - users: FlagUserInfo[]; +export interface GetFeatureFlagUsersResponse { + users: FeatureFlagUserInfo[]; } diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index 90a6be760..0bde32814 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -13,7 +13,7 @@ import { api } from "@/shared/lib/api/client"; import type { FeatureFlagInfo, FeatureFlagScope, GetFeatureFlagsResponse } from "./-components/types"; -import { getFlagDescription, getFlagName } from "./-components/flagLabels"; +import { getFeatureFlagDescription, getFeatureFlagName } from "./-components/flagLabels"; import { ScopeIcon } from "./-components/ScopeIcon"; export const Route = createFileRoute("/feature-flags/")({ @@ -21,12 +21,12 @@ export const Route = createFileRoute("/feature-flags/")({ component: FeatureFlagsPage }); -type FlagGroupKey = "Tenant" | "Plan" | "User" | "System"; +type FeatureFlagGroupKey = "Tenant" | "Plan" | "User" | "System"; -interface FlagGroup { - groupKey: FlagGroupKey; +interface FeatureFlagGroup { + groupKey: FeatureFlagGroupKey; label: string; - flags: FeatureFlagInfo[]; + featureFlags: FeatureFlagInfo[]; } export default function FeatureFlagsPage() { @@ -37,8 +37,8 @@ export default function FeatureFlagsPage() { const groups = useMemo(() => { if (!data?.flags) return []; - const groupOrder: FlagGroupKey[] = ["Tenant", "Plan", "User", "System"]; - const groupLabels: Record = { + const groupOrder: FeatureFlagGroupKey[] = ["Tenant", "Plan", "User", "System"]; + const groupLabels: Record = { Tenant: t`Account flags`, Plan: t`Plan flags`, User: t`User flags`, @@ -48,13 +48,13 @@ export default function FeatureFlagsPage() { .map((groupKey) => ({ groupKey, label: groupLabels[groupKey], - flags: data.flags.filter((flag) => { - if (groupKey === "Plan") return flag.scope === "Tenant" && flag.requiredPlan != null; - if (groupKey === "Tenant") return flag.scope === "Tenant" && flag.requiredPlan == null; - return flag.scope === groupKey; + featureFlags: data.flags.filter((featureFlag) => { + if (groupKey === "Plan") return featureFlag.scope === "Tenant" && featureFlag.requiredPlan != null; + if (groupKey === "Tenant") return featureFlag.scope === "Tenant" && featureFlag.requiredPlan == null; + return featureFlag.scope === groupKey; }) })) - .filter((group) => group.flags.length > 0); + .filter((group) => group.featureFlags.length > 0); }, [data?.flags]); return ( @@ -62,16 +62,16 @@ export default function FeatureFlagsPage() { - {isLoading ? : } + {isLoading ? : } ); } -function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { +function FeatureFlagGroupList({ groups }: Readonly<{ groups: FeatureFlagGroup[] }>) { const navigate = useNavigate(); - const hasDetail = (groupKey: FlagGroupKey, scope: FeatureFlagScope) => + const hasDetail = (groupKey: FeatureFlagGroupKey, scope: FeatureFlagScope) => groupKey === "Plan" || scope === "Tenant" || scope === "User"; return ( @@ -105,44 +105,44 @@ function FlagGroupList({ groups }: Readonly<{ groups: FlagGroup[] }>) { - {group.flags.map((flag) => ( + {group.featureFlags.map((featureFlag) => ( navigate({ to: "/feature-flags/$flagKey", params: { flagKey: flag.key } }) + hasDetail(group.groupKey, featureFlag.scope) + ? () => navigate({ to: "/feature-flags/$flagKey", params: { flagKey: featureFlag.key } }) : undefined } >
- - {getFlagName(flag.key)} + + {getFeatureFlagName(featureFlag.key)} - {getFlagDescription(flag.key) || flag.description} + {getFeatureFlagDescription(featureFlag.key) || featureFlag.description}
{isPlanGroup && ( - {flag.requiredPlan} + {featureFlag.requiredPlan} )} {showRollout && ( - {flag.rolloutPercentage !== null ? ( - `${flag.rolloutPercentage}%` + {featureFlag.rolloutPercentage !== null ? ( + `${featureFlag.rolloutPercentage}%` ) : ( -- )} )} - - {flag.isActive ? t`Active` : t`Inactive`} + + {featureFlag.isActive ? t`Active` : t`Inactive`}
diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 01aa65f22..ad79bdae2 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -47,12 +47,14 @@ msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" #. placeholder {0}: tenant.tenantName -msgid "{flagDescription} disabled for {0}" -msgstr "{flagDescription} deaktiveret for {0}" +#. placeholder {0}: user.email +msgid "{featureFlagDescription} disabled for {0}" +msgstr "{featureFlagDescription} deaktiveret for {0}" #. placeholder {0}: tenant.tenantName -msgid "{flagDescription} enabled for {0}" -msgstr "{flagDescription} aktiveret for {0}" +#. placeholder {0}: user.email +msgid "{featureFlagDescription} enabled for {0}" +msgstr "{featureFlagDescription} aktiveret for {0}" msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inaktive" @@ -230,6 +232,9 @@ msgstr "Samlet MRR" msgid "Browser" msgstr "Browser" +msgid "Bucket" +msgstr "Bucket" + msgid "Canceled" msgstr "Opsagt" @@ -370,12 +375,12 @@ msgstr "Aktiveret" msgid "Enabled ({0})" msgstr "Aktiveret ({0})" -#. placeholder {0}: formatTimestamp(flag.enabledAt) -#. placeholder {1}: formatTimestamp(flag.disabledAt) +#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) +#. placeholder {1}: formatTimestamp(featureFlag.disabledAt) msgid "Enabled period: {0} - {1}" msgstr "Aktiveret periode: {0} - {1}" -#. placeholder {0}: formatTimestamp(flag.enabledAt) +#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) msgid "Enabled: {0}" msgstr "Aktiveret: {0}" @@ -890,11 +895,14 @@ msgstr "Udrulningsspande: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({roll msgid "Rollout %" msgstr "Udrulnings-%" -msgid "Rollout buckets: {bucketStart}-{bucketEnd} ({rolloutPercentage}%)" -msgstr "Udrulningsbuckets: {bucketStart}-{bucketEnd} ({rolloutPercentage}%)" +msgid "Rollout bucket information" +msgstr "Information om udrulnings-bucket" + +msgid "Rollout buckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsbuckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" -msgid "Rollout buckets: {bucketStart}-99 and 0-{bucketEnd} ({rolloutPercentage}%)" -msgstr "Udrulningsbuckets: {bucketStart}-99 og 0-{bucketEnd} ({rolloutPercentage}%)" +msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgstr "Udrulningsbuckets: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({rolloutPercentage}%)" msgid "Rollout percentage" msgstr "Udrulningsprocent" @@ -1085,9 +1093,9 @@ msgstr "Denne bruger har ingen registrerede sessioner." msgid "This user is not a member of any account." msgstr "Denne bruger er ikke medlem af nogen konto." -#. placeholder {0}: flag.description +#. placeholder {0}: getFeatureFlagName(featureFlag.key) msgid "Toggle {0}" -msgstr "" +msgstr "Skift {0}" msgid "Toggle the override switch to enable this feature for specific accounts." msgstr "" diff --git a/application/account/Core/Configuration.cs b/application/account/Core/Configuration.cs index 971f2886f..07877ba78 100644 --- a/application/account/Core/Configuration.cs +++ b/application/account/Core/Configuration.cs @@ -2,7 +2,7 @@ using Account.Features.EmailAuthentication.Shared; using Account.Features.ExternalAuthentication; using Account.Features.ExternalAuthentication.Shared; -using Account.Features.FeatureFlags; +using Account.Features.FeatureFlags.Shared; using Account.Features.Subscriptions.Shared; using Account.Features.Users.Shared; using Account.Integrations.Gravatar; @@ -71,8 +71,8 @@ public IServiceCollection AddAccountServices() .AddScoped() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs index fa2359904..2c7f313d0 100644 --- a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs +++ b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs @@ -14,18 +14,18 @@ public sealed class SeedFeatureFlags(AccountDbContext dbContext) : IDataMigratio public async Task ExecuteAsync(CancellationToken cancellationToken) { - var flags = FeatureFlags.GetAll(); + var featureFlags = FeatureFlags.GetAll(); var now = DateTimeOffset.UtcNow; var seededCount = 0; - foreach (var flag in flags) + foreach (var featureFlag in featureFlags) { - if (flag.Scope == FeatureFlagScope.System) continue; + if (featureFlag.Scope == FeatureFlagScope.System) continue; seededCount++; var id = FeatureFlagId.NewId().Value; - var source = flag.RequiredPlan is not null ? "Plan" : "Manual"; + var source = featureFlag.RequiredPlan is not null ? "Plan" : "Manual"; await dbContext.Database.ExecuteSqlRawAsync( """ @@ -38,10 +38,10 @@ ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET """, [ new NpgsqlParameter("@id", id), - new NpgsqlParameter("@flagKey", flag.Key), + new NpgsqlParameter("@flagKey", featureFlag.Key), new NpgsqlParameter("@now", now), - new NpgsqlParameter("@configurableByTenant", flag.ConfigurableByTenant), - new NpgsqlParameter("@configurableByUser", flag.ConfigurableByUser), + new NpgsqlParameter("@configurableByTenant", featureFlag.ConfigurableByTenant), + new NpgsqlParameter("@configurableByUser", featureFlag.ConfigurableByUser), new NpgsqlParameter("@source", source) ], cancellationToken @@ -50,6 +50,6 @@ ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET await dbContext.SaveChangesAsync(cancellationToken); - return $"Upserted {seededCount} feature flag base rows"; + return $"Upserted {seededCount} feature featureFlag base rows"; } } diff --git a/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs index 6cd60f09f..b35645920 100644 --- a/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs +++ b/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs @@ -16,10 +16,10 @@ public sealed class SeedPlanBasedFeatureFlags(AccountDbContext dbContext) : IDat public async Task ExecuteAsync(CancellationToken cancellationToken) { - var planFlags = FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); - if (planFlags.Length == 0) return "No plan-based feature flags defined"; + var planFeatureFlags = FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); + if (planFeatureFlags.Length == 0) return "No plan-based feature flags defined"; - var planTierMapping = new Dictionary + var planMapping = new Dictionary { [nameof(SubscriptionPlan.Basis)] = PlanTier.Free, [nameof(SubscriptionPlan.Standard)] = PlanTier.Standard, @@ -28,7 +28,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var tenants = await dbContext.Database.SqlQueryRaw( """ - SELECT t.id AS "TenantId", s.plan AS "Plan" + SELECT t.id AS tenant_id, s.plan AS plan FROM tenants t JOIN subscriptions s ON s.tenant_id = t.id WHERE t.deleted_at IS NULL @@ -40,11 +40,11 @@ WHERE t.deleted_at IS NULL foreach (var tenant in tenants) { - if (!planTierMapping.TryGetValue(tenant.Plan, out var tenantPlanTier)) continue; + if (!planMapping.TryGetValue(tenant.Plan, out var tenantSubscriptionPlan)) continue; - foreach (var flag in planFlags) + foreach (var featureFlag in planFeatureFlags) { - var shouldBeEnabled = tenantPlanTier >= flag.RequiredPlan!.Value; + var shouldBeEnabled = tenantSubscriptionPlan >= featureFlag.RequiredPlan!.Value; var id = FeatureFlagId.NewId().Value; await dbContext.Database.ExecuteSqlRawAsync( @@ -58,7 +58,7 @@ ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET """, [ new NpgsqlParameter("@id", id), - new NpgsqlParameter("@flagKey", flag.Key), + new NpgsqlParameter("@flagKey", featureFlag.Key), new NpgsqlParameter("@tenantId", tenant.TenantId), new NpgsqlParameter("@now", now), new NpgsqlParameter("@enabledAt", shouldBeEnabled ? now : DBNull.Value), @@ -73,7 +73,7 @@ ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET await dbContext.SaveChangesAsync(cancellationToken); - return $"Seeded {seededCount} plan-based feature flag overrides across {tenants.Length} tenants"; + return $"Seeded {seededCount} plan-based feature featureFlag overrides across {tenants.Length} tenants"; } [UsedImplicitly] diff --git a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs index 0aa9ce8b4..338cd3378 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs @@ -15,8 +15,8 @@ public sealed class ActivateFeatureFlagValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry."); } } @@ -25,11 +25,11 @@ public sealed class ActivateFeatureFlagHandler(IFeatureFlagRepository featureFla { public async Task Handle(ActivateFeatureFlagCommand command, CancellationToken cancellationToken) { - var flag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); - if (flag is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + var featureFlag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (featureFlag is null) return Result.NotFound($"Feature featureFlag with key '{command.FlagKey}' not found."); - flag.Activate(timeProvider.GetUtcNow()); - featureFlagRepository.Update(flag); + featureFlag.Activate(timeProvider.GetUtcNow()); + featureFlagRepository.Update(featureFlag); await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); diff --git a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs index 235a8a7ac..52a37518d 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs @@ -15,8 +15,8 @@ public sealed class DeactivateFeatureFlagValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry."); } } @@ -25,11 +25,11 @@ public sealed class DeactivateFeatureFlagHandler(IFeatureFlagRepository featureF { public async Task Handle(DeactivateFeatureFlagCommand command, CancellationToken cancellationToken) { - var flag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); - if (flag is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + var featureFlag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (featureFlag is null) return Result.NotFound($"Feature featureFlag with key '{command.FlagKey}' not found."); - flag.Deactivate(timeProvider.GetUtcNow()); - featureFlagRepository.Update(flag); + featureFlag.Deactivate(timeProvider.GetUtcNow()); + featureFlagRepository.Update(featureFlag); await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs index ad0e9853d..e556b5465 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -23,9 +23,9 @@ public sealed class RemoveTenantFeatureFlagOverrideValidator : AbstractValidator public RemoveTenantFeatureFlagOverrideValidator() { RuleFor(x => x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs index 2d942abcb..8e845e3a6 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs @@ -25,9 +25,9 @@ public sealed class RemoveUserFeatureFlagOverrideValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Feature flag must have user scope."); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs index 0bbf9d3a0..25ceb5cdf 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs @@ -21,9 +21,9 @@ public sealed class SetFeatureFlagRolloutPercentageValidator : AbstractValidator public SetFeatureFlagRolloutPercentageValidator() { RuleFor(x => x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.IsAbTestEligible == true).WithMessage("Flag must be eligible for A/B testing."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.IsAbTestEligible == true).WithMessage("Feature flag must be eligible for A/B testing."); RuleFor(x => x.RolloutPercentage) .InclusiveBetween(0, 100).WithMessage("Rollout percentage must be between 0 and 100."); @@ -35,30 +35,30 @@ public sealed class SetFeatureFlagRolloutPercentageHandler(IFeatureFlagRepositor { public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, CancellationToken cancellationToken) { - var flag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); - if (flag is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + var featureFlag = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (featureFlag is null) return Result.NotFound($"Feature featureFlag with key '{command.FlagKey}' not found."); - int? bucketStart; - int? bucketEnd; + int? rolloutBucketStart; + int? rolloutBucketEnd; if (command.RolloutPercentage == 0) { - bucketStart = null; - bucketEnd = null; + rolloutBucketStart = null; + rolloutBucketEnd = null; } else if (command.RolloutPercentage == 100) { - bucketStart = 0; - bucketEnd = 99; + rolloutBucketStart = 0; + rolloutBucketEnd = 99; } else { - bucketStart = ComputeStartingBucket(command.FlagKey); - bucketEnd = (bucketStart.Value + command.RolloutPercentage - 1) % 100; + rolloutBucketStart = ComputeStartingRolloutBucket(command.FlagKey); + rolloutBucketEnd = (rolloutBucketStart.Value + command.RolloutPercentage - 1) % 100; } - flag.SetRolloutRange(bucketStart, bucketEnd); - featureFlagRepository.Update(flag); + featureFlag.SetRolloutRange(rolloutBucketStart, rolloutBucketEnd); + featureFlagRepository.Update(featureFlag); await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); @@ -67,7 +67,7 @@ public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, return Result.Success(); } - private static int ComputeStartingBucket(string flagKey) + private static int ComputeStartingRolloutBucket(string flagKey) { unchecked { diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 41ded4c00..4ce7a826b 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -25,9 +25,9 @@ public sealed class SetTenantFeatureFlagInternalValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs index 90999487f..c0cecce9e 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs @@ -24,9 +24,9 @@ public sealed class SetTenantFeatureFlagOwnerValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs index 9c3f4ae0c..622e39da6 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs @@ -23,9 +23,9 @@ public sealed class SetUserFeatureFlagValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Feature flag must have user scope."); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs index 8a234eff5..f73f0dd48 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -27,9 +27,9 @@ public sealed class SetUserFeatureFlagInternalValidator : AbstractValidator x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Feature flag must have user scope."); } } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index 32f614759..28782696c 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -71,25 +71,25 @@ public void Deactivate(DateTimeOffset now) DisabledAt = now; } - public void SetRolloutRange(int? bucketStart, int? bucketEnd) + public void SetRolloutRange(int? rolloutBucketStart, int? rolloutBucketEnd) { - if (bucketStart is null != bucketEnd is null) + if (rolloutBucketStart is null != rolloutBucketEnd is null) { throw new ArgumentException("Bucket start and bucket end must both be set or both be null."); } - if (bucketStart is not null && (bucketStart < 0 || bucketStart > 99)) + if (rolloutBucketStart is not null && (rolloutBucketStart < 0 || rolloutBucketStart > 99)) { - throw new ArgumentOutOfRangeException(nameof(bucketStart), "Bucket start must be between 0 and 99."); + throw new ArgumentOutOfRangeException(nameof(rolloutBucketStart), "Bucket start must be between 0 and 99."); } - if (bucketEnd is not null && (bucketEnd < 0 || bucketEnd > 99)) + if (rolloutBucketEnd is not null && (rolloutBucketEnd < 0 || rolloutBucketEnd > 99)) { - throw new ArgumentOutOfRangeException(nameof(bucketEnd), "Bucket end must be between 0 and 99."); + throw new ArgumentOutOfRangeException(nameof(rolloutBucketEnd), "Bucket end must be between 0 and 99."); } - BucketStart = bucketStart; - BucketEnd = bucketEnd; + BucketStart = rolloutBucketStart; + BucketEnd = rolloutBucketEnd; } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs new file mode 100644 index 000000000..b38aab8c4 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs @@ -0,0 +1,76 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetFeatureFlagTenantsQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public string FlagKey { get; init; } = null!; +} + +[PublicAPI] +public sealed record GetFeatureFlagTenantsResponse(FeatureFlagTenantInfo[] Tenants); + +[PublicAPI] +public sealed record FeatureFlagTenantInfo( + TenantId TenantId, + string TenantName, + string Plan, + int RolloutBucket, + bool IsEnabled, + string Source +); + +public sealed class GetFeatureFlagTenantsValidator : AbstractValidator +{ + public GetFeatureFlagTenantsValidator() + { + RuleFor(x => x.FlagKey) + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); + } +} + +public sealed class GetFeatureFlagTenantsHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetFeatureFlagTenantsQuery query, CancellationToken cancellationToken) + { + var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); + if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); + + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var tenantOverrides = await featureFlagRepository.GetTenantOverridesForFlagAsync(query.FlagKey, cancellationToken); + var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!.Value); + + var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(query.FlagKey, null, null, cancellationToken); + + var featureFlagTenants = tenants.Select(tenant => + { + if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) + { + var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); + return new FeatureFlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isEnabled, "manual_override"); + } + + if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return new FeatureFlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isInRange, "ab_rollout"); + } + + return new FeatureFlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, false, "default"); + } + ).ToArray(); + + return new GetFeatureFlagTenantsResponse(featureFlagTenants); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs similarity index 56% rename from application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs rename to application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs index d888b40bd..0bf41f19e 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs @@ -10,7 +10,7 @@ namespace Account.Features.FeatureFlags.Queries; [PublicAPI] -public sealed record GetFlagUsersQuery : IRequest> +public sealed record GetFeatureFlagUsersQuery : IRequest> { [JsonIgnore] // Removes from API contract public string FlagKey { get; init; } = null!; @@ -19,10 +19,10 @@ public sealed record GetFlagUsersQuery : IRequest> } [PublicAPI] -public sealed record GetFlagUsersResponse(FlagUserInfo[] Users); +public sealed record GetFeatureFlagUsersResponse(FeatureFlagUserInfo[] Users); [PublicAPI] -public sealed record FlagUserInfo( +public sealed record FeatureFlagUserInfo( UserId UserId, TenantId TenantId, string Email, @@ -32,28 +32,28 @@ public sealed record FlagUserInfo( string Source ); -public sealed class GetFlagUsersValidator : AbstractValidator +public sealed class GetFeatureFlagUsersValidator : AbstractValidator { - public GetFlagUsersValidator() + public GetFeatureFlagUsersValidator() { RuleFor(x => x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Flag must have user scope."); + .NotEmpty().WithMessage("Feature flag key must not be empty.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Feature flag must have user scope."); RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters."); } } -public sealed class GetFlagUsersHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, ITenantRepository tenantRepository) - : IRequestHandler> +public sealed class GetFeatureFlagUsersHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, ITenantRepository tenantRepository) + : IRequestHandler> { - public async Task> Handle(GetFlagUsersQuery query, CancellationToken cancellationToken) + public async Task> Handle(GetFeatureFlagUsersQuery query, CancellationToken cancellationToken) { var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); - if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); + if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); - if (string.IsNullOrWhiteSpace(query.Search)) return new GetFlagUsersResponse([]); + if (string.IsNullOrWhiteSpace(query.Search)) return new GetFeatureFlagUsersResponse([]); var users = await userRepository.SearchByEmailUnfilteredAsync(query.Search.Trim(), cancellationToken); var userOverrides = await featureFlagRepository.GetUserOverridesForFlagAsync(query.FlagKey, cancellationToken); @@ -65,26 +65,26 @@ public async Task> Handle(GetFlagUsersQuery query, var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); var tenantsById = tenants.ToDictionary(t => t.Id); - var flagUsers = users.Select(user => + var featureFlagUsers = users.Select(user => { var tenantName = tenantsById.TryGetValue(user.TenantId, out var tenant) ? tenant.Name : "Unknown"; if (overridesByUserId.TryGetValue(user.Id.Value, out var userOverride)) { var isEnabled = userOverride.EnabledAt is not null && (userOverride.DisabledAt is null || userOverride.EnabledAt > userOverride.DisabledAt); - return new FlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isEnabled, "manual_override"); + return new FeatureFlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isEnabled, "manual_override"); } if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { - var isInRange = RolloutBucketHasher.IsInBucketRange(user.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isInRange, "ab_rollout"); + var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(user.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return new FeatureFlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isInRange, "ab_rollout"); } - return new FlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, false, "default"); + return new FeatureFlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, false, "default"); } ).ToArray(); - return new GetFlagUsersResponse(flagUsers); + return new GetFeatureFlagUsersResponse(featureFlagUsers); } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs index 7f37efe5b..54d8565c8 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs @@ -25,8 +25,8 @@ public sealed record FeatureFlagInfo( DateTimeOffset? CreatedAt, DateTimeOffset? EnabledAt, DateTimeOffset? DisabledAt, - int? BucketStart, - int? BucketEnd, + int? RolloutBucketStart, + int? RolloutBucketEnd, int? RolloutPercentage, bool IsActive ); @@ -40,15 +40,15 @@ public async Task> Handle(GetFeatureFlagsQuery r var baseRows = await featureFlagRepository.GetAllBaseRowsAsync(cancellationToken); var baseRowsByKey = baseRows.ToDictionary(f => f.FlagKey); - var flags = definitions.Select(definition => + var featureFlags = definitions.Select(definition => { if (definition.Scope == FeatureFlagScope.System) { - var isSystemFlagActive = IsSystemFlagEnabled(definition.Key); + var isSystemFeatureFlagActive = definition.IsSystemFeatureFlagEnabled(configuration); return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, definition.RequiredPlan?.ToString(), - null, null, null, null, null, null, isSystemFlagActive + null, null, null, null, null, null, isSystemFeatureFlagActive ); } @@ -57,45 +57,35 @@ public async Task> Handle(GetFeatureFlagsQuery r var createdAt = baseRow?.CreatedAt; var enabledAt = baseRow?.EnabledAt; var disabledAt = baseRow?.DisabledAt; - var bucketStart = baseRow?.BucketStart; - var bucketEnd = baseRow?.BucketEnd; + var rolloutBucketStart = baseRow?.BucketStart; + var rolloutBucketEnd = baseRow?.BucketEnd; var isActive = enabledAt is not null && (disabledAt is null || enabledAt > disabledAt); - var rolloutPercentage = ComputeRolloutPercentage(bucketStart, bucketEnd); + var rolloutPercentage = ComputeRolloutPercentage(rolloutBucketStart, rolloutBucketEnd); return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, definition.RequiredPlan?.ToString(), - createdAt, enabledAt, disabledAt, bucketStart, bucketEnd, rolloutPercentage, isActive + createdAt, enabledAt, disabledAt, rolloutBucketStart, rolloutBucketEnd, rolloutPercentage, isActive ); } ).ToArray(); - return new GetFeatureFlagsResponse(flags); + return new GetFeatureFlagsResponse(featureFlags); } - private bool IsSystemFlagEnabled(string flagKey) + private static int? ComputeRolloutPercentage(int? rolloutBucketStart, int? rolloutBucketEnd) { - return flagKey switch - { - "google-oauth" => !string.IsNullOrEmpty(configuration["OAuth:Google:ClientId"]), - "subscriptions" => configuration["Stripe:SubscriptionEnabled"] == "true", - _ => false - }; - } - - private static int? ComputeRolloutPercentage(int? bucketStart, int? bucketEnd) - { - if (bucketStart is null || bucketEnd is null) return null; + if (rolloutBucketStart is null || rolloutBucketEnd is null) return null; // 100% rollout uses reserved range 0-100 - if (bucketStart == 0 && bucketEnd == 100) return 100; + if (rolloutBucketStart == 0 && rolloutBucketEnd == 100) return 100; - if (bucketStart <= bucketEnd) + if (rolloutBucketStart <= rolloutBucketEnd) { - return bucketEnd.Value - bucketStart.Value + 1; + return rolloutBucketEnd.Value - rolloutBucketStart.Value + 1; } // Wrap-around case within 1-99 range - return 99 - bucketStart.Value + 1 + bucketEnd.Value; + return 99 - rolloutBucketStart.Value + 1 + rolloutBucketEnd.Value; } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs deleted file mode 100644 index e9bd76b57..000000000 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFlagTenants.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; -using FluentValidation; -using JetBrains.Annotations; -using SharedKernel.Cqrs; -using SharedKernel.Domain; -using SharedKernel.FeatureFlags; - -namespace Account.Features.FeatureFlags.Queries; - -[PublicAPI] -public sealed record GetFlagTenantsQuery : IRequest> -{ - [JsonIgnore] // Removes from API contract - public string FlagKey { get; init; } = null!; -} - -[PublicAPI] -public sealed record GetFlagTenantsResponse(FlagTenantInfo[] Tenants); - -[PublicAPI] -public sealed record FlagTenantInfo( - TenantId TenantId, - string TenantName, - string Plan, - int RolloutBucket, - bool IsEnabled, - string Source -); - -public sealed class GetFlagTenantsValidator : AbstractValidator -{ - public GetFlagTenantsValidator() - { - RuleFor(x => x.FlagKey) - .NotEmpty().WithMessage("Flag key must not be empty.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Flag must have tenant scope."); - } -} - -public sealed class GetFlagTenantsHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository) - : IRequestHandler> -{ - public async Task> Handle(GetFlagTenantsQuery query, CancellationToken cancellationToken) - { - var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); - if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); - - var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); - var tenantOverrides = await featureFlagRepository.GetTenantOverridesForFlagAsync(query.FlagKey, cancellationToken); - var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!.Value); - - var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(query.FlagKey, null, null, cancellationToken); - - var flagTenants = tenants.Select(tenant => - { - if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) - { - var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); - return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isEnabled, "manual_override"); - } - - if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) - { - var isInRange = RolloutBucketHasher.IsInBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isInRange, "ab_rollout"); - } - - return new FlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, false, "default"); - } - ).ToArray(); - - return new GetFlagTenantsResponse(flagTenants); - } -} diff --git a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs b/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs similarity index 73% rename from application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs rename to application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs index 1a1c7c3a4..98e0b1f2c 100644 --- a/application/account/Core/Features/FeatureFlags/FeatureFlagEvaluationService.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs @@ -1,21 +1,21 @@ using Account.Features.FeatureFlags.Domain; using SharedKernel.FeatureFlags; -namespace Account.Features.FeatureFlags; +namespace Account.Features.FeatureFlags.Shared; -public sealed class FeatureFlagEvaluationService(IFeatureFlagRepository featureFlagRepository) +public sealed class FeatureFlagEvaluator(IFeatureFlagRepository featureFlagRepository) { public async Task> EvaluateAsync(long tenantId, string userId, int tenantRolloutBucket, int? userRolloutBucket, CancellationToken cancellationToken) { var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, userId, cancellationToken); - var enabledFlags = new List(); + var enabledFeatureFlags = new List(); var definitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll(); - // Sort flags so parents are evaluated before children + // Sort feature flags so parents are evaluated before children var sorted = TopologicalSort(definitions); - var enabledSet = new HashSet(); + var enabledFeatureFlagSet = new HashSet(); foreach (var definition in sorted) { @@ -26,7 +26,7 @@ public async Task> EvaluateAsync(long tenantId, string use if (!IsActive(baseRow)) continue; - if (definition.ParentDependency is not null && !enabledSet.Contains(definition.ParentDependency)) continue; + if (definition.ParentDependency is not null && !enabledFeatureFlagSet.Contains(definition.ParentDependency)) continue; var isEnabled = definition.Scope switch { @@ -37,11 +37,11 @@ public async Task> EvaluateAsync(long tenantId, string use if (!isEnabled) continue; - enabledSet.Add(definition.Key); - enabledFlags.Add(definition.Key); + enabledFeatureFlagSet.Add(definition.Key); + enabledFeatureFlags.Add(definition.Key); } - return enabledFlags; + return enabledFeatureFlags; } private static bool EvaluateTenantScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, long tenantId, int tenantRolloutBucket) @@ -54,7 +54,7 @@ private static bool EvaluateTenantScope(FeatureFlagDefinition definition, Featur if (definition.IsAbTestEligible && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) { - return RolloutBucketHasher.IsInBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return RolloutBucketHasher.IsInRolloutBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); } return false; @@ -72,22 +72,22 @@ private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureF if (definition.IsAbTestEligible && userRolloutBucket is not null && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) { - return RolloutBucketHasher.IsInBucketRange(userRolloutBucket.Value, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return RolloutBucketHasher.IsInRolloutBucketRange(userRolloutBucket.Value, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); } return false; } - private static bool IsActive(FeatureFlag flag) + private static bool IsActive(FeatureFlag featureFlag) { - return flag.EnabledAt is not null && (flag.DisabledAt is null || flag.EnabledAt > flag.DisabledAt); + return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); } private static FeatureFlagDefinition[] TopologicalSort(FeatureFlagDefinition[] definitions) { var result = new List(definitions.Length); - // Add flags without parent dependencies first + // Add feature flags without parent dependencies first foreach (var definition in definitions) { if (definition.ParentDependency is null) @@ -96,7 +96,7 @@ private static FeatureFlagDefinition[] TopologicalSort(FeatureFlagDefinition[] d } } - // Then add flags with parent dependencies + // Then add feature flags with parent dependencies foreach (var definition in definitions) { if (definition.ParentDependency is not null) diff --git a/application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs similarity index 66% rename from application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs rename to application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs index 2df7067c8..eb7198f82 100644 --- a/application/account/Core/Features/FeatureFlags/PlanBasedFeatureFlagService.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs @@ -4,34 +4,34 @@ using SharedKernel.Domain; using SharedKernel.FeatureFlags; -namespace Account.Features.FeatureFlags; +namespace Account.Features.FeatureFlags.Shared; -public sealed class PlanBasedFeatureFlagService(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider) +public sealed class PlanBasedFeatureFlagEvaluator(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider) { public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, SubscriptionPlan subscriptionPlan, CancellationToken cancellationToken) { - var planTier = MapToPlanTier(subscriptionPlan); - var planFlagDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); + var subscriptionPlanTier = MapToPlanTier(subscriptionPlan); + var planFeatureFlagDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); - if (planFlagDefinitions.Length == 0) return; + if (planFeatureFlagDefinitions.Length == 0) return; var existingOverrides = await featureFlagRepository.GetPlanBasedOverridesForTenantAsync(tenantId.Value, cancellationToken); var overridesByKey = existingOverrides.ToDictionary(f => f.FlagKey); var now = timeProvider.GetUtcNow(); var changed = false; - foreach (var definition in planFlagDefinitions) + foreach (var definition in planFeatureFlagDefinitions) { - var shouldBeEnabled = planTier >= definition.RequiredPlan!.Value; + var shouldBeEnabled = subscriptionPlanTier >= definition.RequiredPlan!.Value; overridesByKey.TryGetValue(definition.Key, out var existingOverride); if (shouldBeEnabled) { if (existingOverride is null) { - var flag = FeatureFlag.CreateTenantOverride(definition.Key, tenantId.Value, FeatureFlagSource.Plan); - flag.Activate(now); - await featureFlagRepository.AddAsync(flag, cancellationToken); + var featureFlag = FeatureFlag.CreateTenantOverride(definition.Key, tenantId.Value, FeatureFlagSource.Plan); + featureFlag.Activate(now); + await featureFlagRepository.AddAsync(featureFlag, cancellationToken); changed = true; } else if (!IsActive(existingOverride)) @@ -63,9 +63,9 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio } } - private static bool IsActive(FeatureFlag flag) + private static bool IsActive(FeatureFlag featureFlag) { - return flag.EnabledAt is not null && (flag.DisabledAt is null || flag.EnabledAt > flag.DisabledAt); + return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); } private static PlanTier MapToPlanTier(SubscriptionPlan plan) diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index d32fecbeb..1cc67a572 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -1,7 +1,7 @@ using System.Data; using System.Globalization; using Account.Database; -using Account.Features.FeatureFlags; +using Account.Features.FeatureFlags.Shared; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; using Account.Integrations.Stripe; @@ -31,7 +31,7 @@ public sealed class ProcessPendingStripeEvents( StripeClientFactory stripeClientFactory, TimeProvider timeProvider, ITelemetryEventsCollector events, - PlanBasedFeatureFlagService planBasedFeatureFlagService, + PlanBasedFeatureFlagEvaluator planBasedFeatureFlagEvaluator, TelemetryClient telemetryClient, ILogger logger ) @@ -112,7 +112,7 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, PendingWebhook if (subscription.Plan != previousPlan) { - await planBasedFeatureFlagService.EvaluatePlanFlagsForTenantAsync(subscription.TenantId, subscription.Plan, cancellationToken); + await planBasedFeatureFlagEvaluator.EvaluatePlanFlagsForTenantAsync(subscription.TenantId, subscription.Plan, cancellationToken); } await dbContext.SaveChangesAsync(cancellationToken); diff --git a/application/account/Core/Features/Tenants/Domain/Tenant.cs b/application/account/Core/Features/Tenants/Domain/Tenant.cs index 3d22ee5ac..a27165aa2 100644 --- a/application/account/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account/Core/Features/Tenants/Domain/Tenant.cs @@ -32,7 +32,7 @@ private Tenant(int rolloutBucket) : base(TenantId.NewId()) public static Tenant Create(string email, int existingCount) { - var tenant = new Tenant(RolloutBucketHasher.ComputeBucket(existingCount)); + var tenant = new Tenant(RolloutBucketHasher.ComputeRolloutBucket(existingCount)); tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, email)); return tenant; } diff --git a/application/account/Core/Features/Users/Domain/User.cs b/application/account/Core/Features/Users/Domain/User.cs index e69174f4f..dc0e5a614 100644 --- a/application/account/Core/Features/Users/Domain/User.cs +++ b/application/account/Core/Features/Users/Domain/User.cs @@ -53,7 +53,7 @@ public string Email public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale, int existingCount) { - return new User(tenantId, email, role, emailConfirmed, locale, RolloutBucketHasher.ComputeBucket(existingCount)); + return new User(tenantId, email, role, emailConfirmed, locale, RolloutBucketHasher.ComputeRolloutBucket(existingCount)); } public void Update(string firstName, string lastName, string title) diff --git a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs index 55edd5313..445700a4e 100644 --- a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs @@ -1,4 +1,4 @@ -using Account.Features.FeatureFlags; +using Account.Features.FeatureFlags.Shared; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; using Account.Features.Users.Domain; @@ -12,7 +12,7 @@ namespace Account.Features.Users.Shared; /// Factory for creating UserInfo instances with tenant information. /// Centralizes the logic for creating UserInfo to follow SRP and avoid duplication. /// -public sealed class UserInfoFactory(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, FeatureFlagEvaluationService featureFlagEvaluationService, PlanBasedFeatureFlagService planBasedFeatureFlagService) +public sealed class UserInfoFactory(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, FeatureFlagEvaluator featureFlagEvaluator, PlanBasedFeatureFlagEvaluator planBasedFeatureFlagEvaluator) { /// /// Creates a UserInfo instance from a User entity, including tenant name. @@ -25,9 +25,9 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(user.TenantId, cancellationToken); - await planBasedFeatureFlagService.EvaluatePlanFlagsForTenantAsync(tenant.Id, subscription!.Plan, cancellationToken); + await planBasedFeatureFlagEvaluator.EvaluatePlanFlagsForTenantAsync(tenant.Id, subscription!.Plan, cancellationToken); - var enabledFlags = await featureFlagEvaluationService.EvaluateAsync(tenant.Id.Value, user.Id.Value, tenant.RolloutBucket, user.RolloutBucket, cancellationToken); + var enabledFlags = await featureFlagEvaluator.EvaluateAsync(tenant.Id.Value, user.Id.Value, tenant.RolloutBucket, user.RolloutBucket, cancellationToken); return new UserInfo { diff --git a/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs similarity index 95% rename from application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs rename to application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs index 3978c2d0e..6708d0b8a 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagEvaluationServiceTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs @@ -1,6 +1,6 @@ using Account.Database; -using Account.Features.FeatureFlags; using Account.Features.FeatureFlags.Domain; +using Account.Features.FeatureFlags.Shared; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; @@ -10,15 +10,15 @@ namespace Account.Tests.FeatureFlags; -public sealed class FeatureFlagEvaluationServiceTests : EndpointBaseTest +public sealed class FeatureFlagEvaluatorTests : EndpointBaseTest { - private readonly FeatureFlagEvaluationService _evaluationService; + private readonly FeatureFlagEvaluator _evaluationService; private readonly IServiceScope _scope; - public FeatureFlagEvaluationServiceTests() + public FeatureFlagEvaluatorTests() { _scope = Provider.CreateScope(); - _evaluationService = _scope.ServiceProvider.GetRequiredService(); + _evaluationService = _scope.ServiceProvider.GetRequiredService(); } [Fact] @@ -214,7 +214,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - private void InsertFeatureFlag(string flagKey, long? tenantId, string? userId, DateTimeOffset? enabledAt, DateTimeOffset? disabledAt, int? bucketStart, int? bucketEnd) + private void InsertFeatureFlag(string flagKey, long? tenantId, string? userId, DateTimeOffset? enabledAt, DateTimeOffset? disabledAt, int? rolloutBucketStart, int? rolloutBucketEnd) { // Delete any seeded row with the same scope to avoid unique constraint conflicts using var deleteCommand = new SqliteCommand( @@ -241,8 +241,8 @@ tenantId is null && userId is null ("modified_at", null), ("enabled_at", enabledAt), ("disabled_at", disabledAt), - ("bucket_start", bucketStart), - ("bucket_end", bucketEnd), + ("bucket_start", rolloutBucketStart), + ("bucket_end", rolloutBucketEnd), ("configurable_by_tenant", false), ("configurable_by_user", false), ("source", "Manual") diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 15e445b02..b30af56fd 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -470,7 +470,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldRetur } [Fact] - public async Task GetFlagUsers_WhenNoSearchProvided_ShouldReturnEmptyArray() + public async Task GetFeatureFlagUsers_WhenNoSearchProvided_ShouldReturnEmptyArray() { // Arrange var flagKey = "compact-view"; @@ -480,13 +480,13 @@ public async Task GetFlagUsers_WhenNoSearchProvided_ShouldReturnEmptyArray() // Assert response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Users.Should().BeEmpty(); } [Fact] - public async Task GetFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchingUsersWithDefaultSource() + public async Task GetFeatureFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchingUsersWithDefaultSource() { // Arrange var flagKey = "compact-view"; @@ -496,14 +496,14 @@ public async Task GetFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchingUsersW // Assert response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Users.Should().NotBeEmpty(); result.Users.Should().OnlyContain(u => u.Source == "default"); } [Fact] - public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOverrideSource() + public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOverrideSource() { // Arrange var flagKey = "compact-view"; @@ -532,7 +532,7 @@ public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOve // Assert response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Users.Should().NotBeEmpty(); var userResult = result.Users.Single(u => u.UserId.Value == userId); @@ -543,7 +543,7 @@ public async Task GetFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOve } [Fact] - public async Task GetFlagUsers_WhenNonUserScopedFlag_ShouldReturnBadRequest() + public async Task GetFeatureFlagUsers_WhenNonUserScopedFlag_ShouldReturnBadRequest() { // Act var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/sso/users"); @@ -567,15 +567,15 @@ public async Task SetFeatureFlagRolloutPercentage_WhenValidPercentage_ShouldUpda // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var bucketStart = Connection.ExecuteScalar( + var rolloutBucketStart = Connection.ExecuteScalar( "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - var bucketEnd = Connection.ExecuteScalar( + var rolloutBucketEnd = Connection.ExecuteScalar( "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - bucketStart.Should().NotBeNull(); - bucketEnd.Should().NotBeNull(); - CountBucketsInRange((int)bucketStart.Value, (int)bucketEnd.Value).Should().Be(50); + rolloutBucketStart.Should().NotBeNull(); + rolloutBucketEnd.Should().NotBeNull(); + CountBucketsInRange((int)rolloutBucketStart.Value, (int)rolloutBucketEnd.Value).Should().Be(50); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); @@ -599,15 +599,15 @@ public async Task SetFeatureFlagRolloutPercentage_WhenSetToNPercent_ShouldInclud // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var bucketStart = Connection.ExecuteScalar( + var rolloutBucketStart = Connection.ExecuteScalar( "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - var bucketEnd = Connection.ExecuteScalar( + var rolloutBucketEnd = Connection.ExecuteScalar( "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - bucketStart.Should().NotBeNull(); - bucketEnd.Should().NotBeNull(); - CountBucketsInRange((int)bucketStart.Value, (int)bucketEnd.Value).Should().Be(percentage); + rolloutBucketStart.Should().NotBeNull(); + rolloutBucketEnd.Should().NotBeNull(); + CountBucketsInRange((int)rolloutBucketStart.Value, (int)rolloutBucketEnd.Value).Should().Be(percentage); } [Fact] @@ -660,14 +660,14 @@ public async Task SetFeatureFlagRolloutPercentage_WhenZeroPercent_ShouldClearBuc // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var bucketStart = Connection.ExecuteScalar( + var rolloutBucketStart = Connection.ExecuteScalar( "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - var bucketEnd = Connection.ExecuteScalar( + var rolloutBucketEnd = Connection.ExecuteScalar( "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - bucketStart.Should().BeNull(); - bucketEnd.Should().BeNull(); + rolloutBucketStart.Should().BeNull(); + rolloutBucketEnd.Should().BeNull(); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); @@ -687,14 +687,14 @@ public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFu // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var bucketStart = Connection.ExecuteScalar( + var rolloutBucketStart = Connection.ExecuteScalar( "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - var bucketEnd = Connection.ExecuteScalar( + var rolloutBucketEnd = Connection.ExecuteScalar( "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); - bucketStart.Should().Be(0); - bucketEnd.Should().Be(99); + rolloutBucketStart.Should().Be(0); + rolloutBucketEnd.Should().Be(99); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); @@ -732,7 +732,7 @@ public async Task GetUserConfigurableFlags_WhenCalled_ShouldReturnConfigurableUs // Flag tenants query tests [Fact] - public async Task GetFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWithDefaultSource() + public async Task GetFeatureFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWithDefaultSource() { // Arrange var flagKey = "sso"; @@ -742,7 +742,7 @@ public async Task GetFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWith // Assert response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Tenants.Should().NotBeEmpty(); result.Tenants.Should().AllSatisfy(t => @@ -754,7 +754,7 @@ public async Task GetFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWith } [Fact] - public async Task GetFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrideSource() + public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrideSource() { // Arrange var flagKey = "sso"; @@ -782,7 +782,7 @@ public async Task GetFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrid // Assert response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); var tenantResult = result.Tenants.Single(t => t.TenantId.Value == tenantId); tenantResult.IsEnabled.Should().BeTrue(); @@ -790,7 +790,7 @@ public async Task GetFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrid } [Fact] - public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource() + public async Task GetFeatureFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource() { // Arrange var flagKey = "beta-features"; @@ -808,7 +808,7 @@ public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource( // Assert response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Tenants.Should().AllSatisfy(t => { @@ -819,7 +819,7 @@ public async Task GetFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource( } [Fact] - public async Task GetFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActive_ShouldReturnManualOverrideDisabled() + public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActive_ShouldReturnManualOverrideDisabled() { // Arrange - set up A/B rollout at 100% so all tenants are enabled var flagKey = "beta-features"; @@ -857,7 +857,7 @@ public async Task GetFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActi // Assert - manual override should take precedence over A/B rollout response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); + var result = await response.DeserializeResponse(); result.Should().NotBeNull(); var tenantResult = result.Tenants.Single(t => t.TenantId.Value == tenantId); tenantResult.IsEnabled.Should().BeFalse(); @@ -865,7 +865,7 @@ public async Task GetFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActi } [Fact] - public async Task GetFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() + public async Task GetFeatureFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() { // Act var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/non-existent/tenants"); @@ -875,7 +875,7 @@ public async Task GetFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() } [Fact] - public async Task GetFlagTenants_WhenSystemScopedFlag_ShouldReturnBadRequest() + public async Task GetFeatureFlagTenants_WhenSystemScopedFlag_ShouldReturnBadRequest() { // Act var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/google-oauth/tenants"); @@ -931,25 +931,25 @@ public async Task GetFeatureFlags_WhenCalled_ShouldReturnAllFlagsWithDatabaseSta public void BucketRange_WhenNormalRange_ShouldMatchCorrectly() { // Arrange & Act & Assert - IsInBucketRange(50, 40, 60).Should().BeTrue(); - IsInBucketRange(39, 40, 60).Should().BeFalse(); - IsInBucketRange(61, 40, 60).Should().BeFalse(); - IsInBucketRange(40, 40, 60).Should().BeTrue(); - IsInBucketRange(60, 40, 60).Should().BeTrue(); + IsInRolloutBucketRange(50, 40, 60).Should().BeTrue(); + IsInRolloutBucketRange(39, 40, 60).Should().BeFalse(); + IsInRolloutBucketRange(61, 40, 60).Should().BeFalse(); + IsInRolloutBucketRange(40, 40, 60).Should().BeTrue(); + IsInRolloutBucketRange(60, 40, 60).Should().BeTrue(); } [Fact] public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() { // Arrange & Act & Assert (wrap-around within 0-99 range) - IsInBucketRange(95, 90, 10).Should().BeTrue(); - IsInBucketRange(5, 90, 10).Should().BeTrue(); - IsInBucketRange(50, 90, 10).Should().BeFalse(); - IsInBucketRange(90, 90, 10).Should().BeTrue(); - IsInBucketRange(10, 90, 10).Should().BeTrue(); - IsInBucketRange(11, 90, 10).Should().BeFalse(); - IsInBucketRange(89, 90, 10).Should().BeFalse(); - IsInBucketRange(0, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(95, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(5, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(50, 90, 10).Should().BeFalse(); + IsInRolloutBucketRange(90, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(10, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(11, 90, 10).Should().BeFalse(); + IsInRolloutBucketRange(89, 90, 10).Should().BeFalse(); + IsInRolloutBucketRange(0, 90, 10).Should().BeTrue(); } [Fact] @@ -959,8 +959,8 @@ public void RolloutBucket_ShouldBeDeterministic() var sequenceNumber = 42; // Act - var bucket1 = RolloutBucketHasher.ComputeBucket(sequenceNumber); - var bucket2 = RolloutBucketHasher.ComputeBucket(sequenceNumber); + var bucket1 = RolloutBucketHasher.ComputeRolloutBucket(sequenceNumber); + var bucket2 = RolloutBucketHasher.ComputeRolloutBucket(sequenceNumber); // Assert bucket1.Should().Be(bucket2); @@ -976,7 +976,7 @@ public void VanDerCorput_ShouldDistributeEvenly() // Act for (var i = 0; i < 1000; i++) { - var bucket = RolloutBucketHasher.ComputeBucket(i); + var bucket = RolloutBucketHasher.ComputeRolloutBucket(i); bucket.Should().BeInRange(0, 99); bucketCounts[bucket]++; } @@ -1126,18 +1126,18 @@ public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllT updatedVersion.Should().Be(originalVersion + 1); } - private static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) + private static bool IsInRolloutBucketRange(int bucket, int rolloutBucketStart, int rolloutBucketEnd) { - return RolloutBucketHasher.IsInBucketRange(bucket, bucketStart, bucketEnd); + return RolloutBucketHasher.IsInRolloutBucketRange(bucket, rolloutBucketStart, rolloutBucketEnd); } - private static int CountBucketsInRange(int bucketStart, int bucketEnd) + private static int CountBucketsInRange(int rolloutBucketStart, int rolloutBucketEnd) { - if (bucketStart <= bucketEnd) + if (rolloutBucketStart <= rolloutBucketEnd) { - return bucketEnd - bucketStart + 1; + return rolloutBucketEnd - rolloutBucketStart + 1; } - return 100 - bucketStart + bucketEnd + 1; + return 100 - rolloutBucketStart + rolloutBucketEnd + 1; } } diff --git a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs similarity index 95% rename from application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs rename to application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs index d52c69b5a..501f15a43 100644 --- a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagServiceTests.cs +++ b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs @@ -1,5 +1,5 @@ using Account.Database; -using Account.Features.FeatureFlags; +using Account.Features.FeatureFlags.Shared; using Account.Features.Subscriptions.Domain; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -8,15 +8,15 @@ namespace Account.Tests.FeatureFlags; -public sealed class PlanBasedFeatureFlagServiceTests : EndpointBaseTest +public sealed class PlanBasedFeatureFlagEvaluatorTests : EndpointBaseTest { private readonly AccountDbContext _dbContext; - private readonly PlanBasedFeatureFlagService _service; + private readonly PlanBasedFeatureFlagEvaluator _service; - public PlanBasedFeatureFlagServiceTests() + public PlanBasedFeatureFlagEvaluatorTests() { var scope = Provider.CreateScope(); - _service = scope.ServiceProvider.GetRequiredService(); + _service = scope.ServiceProvider.GetRequiredService(); _dbContext = scope.ServiceProvider.GetRequiredService(); } diff --git a/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx b/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx index cb544c3ce..7ef552609 100644 --- a/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx +++ b/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx @@ -36,8 +36,8 @@ export function FeaturesSection() { Toggle features available to your account.

- {tenantFlags.map((flag) => ( - + {tenantFlags.map((f) => ( + ))}
diff --git a/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx b/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx index 7e274980e..40bf5ee12 100644 --- a/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx +++ b/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx @@ -34,8 +34,8 @@ export function BetaFeaturesSection() { Opt in to try new features before they are available to everyone.

- {userFlags.map((flag) => ( - + {userFlags.map((f) => ( + ))}
diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs index 80aff6e63..5f6ee8b12 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; + namespace SharedKernel.FeatureFlags; [PublicAPI] @@ -12,5 +14,19 @@ public sealed record FeatureFlagDefinition( bool ConfigurableByUser = false, bool TrackInTelemetry = false, string? TelemetryName = null, - PlanTier? RequiredPlan = null -); + PlanTier? RequiredPlan = null, + string? SystemConfigKey = null, + string? SystemConfigExpectedValue = null +) +{ + public bool IsSystemFeatureFlagEnabled(IConfiguration configuration) + { + if (Scope != FeatureFlagScope.System || SystemConfigKey is null) return false; + + var configValue = configuration[SystemConfigKey]; + + return SystemConfigExpectedValue is not null + ? configValue == SystemConfigExpectedValue + : !string.IsNullOrEmpty(configValue); + } +} diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs index 7306193e4..e2e32161a 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -7,14 +7,17 @@ public static class FeatureFlags "google-oauth", FeatureFlagScope.System, FeatureFlagAdminLevel.SystemAdmin, - "Google OAuth authentication" + "Google OAuth authentication", + SystemConfigKey: "OAuth:Google:ClientId" ); public static readonly FeatureFlagDefinition Subscriptions = new( "subscriptions", FeatureFlagScope.System, FeatureFlagAdminLevel.SystemAdmin, - "Subscription billing via Stripe" + "Subscription billing via Stripe", + SystemConfigKey: "Stripe:SubscriptionEnabled", + SystemConfigExpectedValue: "true" ); public static readonly FeatureFlagDefinition BetaFeatures = new( @@ -59,7 +62,7 @@ public static class FeatureFlags TrackInTelemetry: true ); - private static readonly FeatureFlagDefinition[] AllFlags = [GoogleOauth, Subscriptions, BetaFeatures, Sso, CustomBranding, CompactView, ExperimentalUi]; + private static readonly FeatureFlagDefinition[] AllFeatureFlags = [GoogleOauth, Subscriptions, BetaFeatures, Sso, CustomBranding, CompactView, ExperimentalUi]; static FeatureFlags() { @@ -68,88 +71,98 @@ static FeatureFlags() public static FeatureFlagDefinition[] GetAll() { - return AllFlags; + return AllFeatureFlags; } public static FeatureFlagDefinition? Get(string key) { - return AllFlags.FirstOrDefault(f => f.Key == key); + return AllFeatureFlags.FirstOrDefault(f => f.Key == key); } private static void ValidateFlags() { - var flagsByKey = AllFlags.ToDictionary(f => f.Key); + var featureFlagsByKey = AllFeatureFlags.ToDictionary(f => f.Key); - foreach (var flag in AllFlags) + foreach (var featureFlag in AllFeatureFlags) { - if (flag.Key.Length > 50) + if (featureFlag.Key.Length > 50) { - throw new InvalidOperationException($"Feature flag key '{flag.Key}' exceeds 50 characters."); + throw new InvalidOperationException($"Feature flag key '{featureFlag.Key}' exceeds 50 characters."); } - if (flag.Key.Contains(',')) + if (featureFlag.Key.Contains(',')) { - throw new InvalidOperationException($"Feature flag key '{flag.Key}' must not contain commas."); + throw new InvalidOperationException($"Feature flag key '{featureFlag.Key}' must not contain commas."); } - switch (flag.Scope) + if (featureFlag is { Scope: FeatureFlagScope.System, SystemConfigKey: null }) { - case FeatureFlagScope.System when flag.AdminLevel != FeatureFlagAdminLevel.SystemAdmin: - throw new InvalidOperationException($"Feature flag '{flag.Key}' with System scope must use SystemAdmin admin level."); - case FeatureFlagScope.Tenant when flag.AdminLevel is not (FeatureFlagAdminLevel.SystemAdmin or FeatureFlagAdminLevel.TenantOwner): - throw new InvalidOperationException($"Feature flag '{flag.Key}' with Tenant scope must use SystemAdmin or TenantOwner admin level."); - case FeatureFlagScope.User when flag.AdminLevel != FeatureFlagAdminLevel.User: - throw new InvalidOperationException($"Feature flag '{flag.Key}' with User scope must use User admin level."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with System scope must define a SystemConfigKey."); } - if (flag.ConfigurableByTenant && (flag.Scope != FeatureFlagScope.Tenant || flag.AdminLevel != FeatureFlagAdminLevel.TenantOwner)) + if (featureFlag.Scope != FeatureFlagScope.System && featureFlag.SystemConfigKey is not null) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' can only be ConfigurableByTenant when Scope=Tenant and AdminLevel=TenantOwner."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' must not define SystemConfigKey unless Scope is System."); } - if (flag.ConfigurableByUser && (flag.Scope != FeatureFlagScope.User || flag.AdminLevel != FeatureFlagAdminLevel.User)) + switch (featureFlag.Scope) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' can only be ConfigurableByUser when Scope=User and AdminLevel=User."); + case FeatureFlagScope.System when featureFlag.AdminLevel != FeatureFlagAdminLevel.SystemAdmin: + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with System scope must use SystemAdmin admin level."); + case FeatureFlagScope.Tenant when featureFlag.AdminLevel is not (FeatureFlagAdminLevel.SystemAdmin or FeatureFlagAdminLevel.TenantOwner): + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with Tenant scope must use SystemAdmin or TenantOwner admin level."); + case FeatureFlagScope.User when featureFlag.AdminLevel != FeatureFlagAdminLevel.User: + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with User scope must use User admin level."); } - if (flag is { ConfigurableByTenant: true, IsAbTestEligible: true }) + if (featureFlag.ConfigurableByTenant && (featureFlag.Scope != FeatureFlagScope.Tenant || featureFlag.AdminLevel != FeatureFlagAdminLevel.TenantOwner)) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' cannot be both ConfigurableByTenant and IsAbTestEligible."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' can only be ConfigurableByTenant when Scope=Tenant and AdminLevel=TenantOwner."); } - if (flag.RequiredPlan is not null) + if (featureFlag.ConfigurableByUser && (featureFlag.Scope != FeatureFlagScope.User || featureFlag.AdminLevel != FeatureFlagAdminLevel.User)) { - if (flag.Scope != FeatureFlagScope.Tenant) + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' can only be ConfigurableByUser when Scope=User and AdminLevel=User."); + } + + if (featureFlag is { ConfigurableByTenant: true, IsAbTestEligible: true }) + { + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' cannot be both ConfigurableByTenant and IsAbTestEligible."); + } + + if (featureFlag.RequiredPlan is not null) + { + if (featureFlag.Scope != FeatureFlagScope.Tenant) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan must have Tenant scope."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with RequiredPlan must have Tenant scope."); } - if (flag.ConfigurableByTenant) + if (featureFlag.ConfigurableByTenant) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan cannot be ConfigurableByTenant."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with RequiredPlan cannot be ConfigurableByTenant."); } - if (flag.ConfigurableByUser) + if (featureFlag.ConfigurableByUser) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan cannot be ConfigurableByUser."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with RequiredPlan cannot be ConfigurableByUser."); } - if (flag.IsAbTestEligible) + if (featureFlag.IsAbTestEligible) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' with RequiredPlan cannot be IsAbTestEligible."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with RequiredPlan cannot be IsAbTestEligible."); } } - if (flag.ParentDependency is not null) + if (featureFlag.ParentDependency is not null) { - if (!flagsByKey.TryGetValue(flag.ParentDependency, out var parent)) + if (!featureFlagsByKey.TryGetValue(featureFlag.ParentDependency, out var parent)) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' references non-existent parent dependency '{flag.ParentDependency}'."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' references non-existent parent dependency '{featureFlag.ParentDependency}'."); } if (parent.ParentDependency is not null) { - throw new InvalidOperationException($"Feature flag '{flag.Key}' has parent '{flag.ParentDependency}' which itself has a parent dependency. Only one level of dependency is allowed."); + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' has parent '{featureFlag.ParentDependency}' which itself has a parent dependency. Only one level of dependency is allowed."); } } } diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs index 27a96d6ba..b71ec9628 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/RolloutBucketHasher.cs @@ -2,21 +2,21 @@ namespace SharedKernel.FeatureFlags; public static class RolloutBucketHasher { - public static int ComputeBucket(int sequenceNumber) + public static int ComputeRolloutBucket(int sequenceNumber) { var value = VanDerCorput(sequenceNumber); return (int)(value * 100); } - public static bool IsInBucketRange(int bucket, int bucketStart, int bucketEnd) + public static bool IsInRolloutBucketRange(int bucket, int rolloutBucketStart, int rolloutBucketEnd) { - if (bucketStart <= bucketEnd) + if (rolloutBucketStart <= rolloutBucketEnd) { - return bucket >= bucketStart && bucket <= bucketEnd; + return bucket >= rolloutBucketStart && bucket <= rolloutBucketEnd; } // Wrap-around case (e.g., start=90, end=10 means 90-99 and 0-10) - return bucket >= bucketStart || bucket <= bucketEnd; + return bucket >= rolloutBucketStart || bucket <= rolloutBucketEnd; } private static double VanDerCorput(int n) diff --git a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs index 3c6416cb7..cc85b4d95 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs @@ -69,11 +69,11 @@ public void Initialize(ITelemetry telemetry) AddCustomProperty(telemetry, "user.role", executionContext.UserInfo.Role); AddCustomProperty(telemetry, "user.session_id", executionContext.UserInfo.SessionId?.Value); - foreach (var flag in FeatureFlags.FeatureFlags.GetAll()) + foreach (var featureFlag in FeatureFlags.FeatureFlags.GetAll()) { - if (!flag.TrackInTelemetry) continue; - var telemetryName = flag.TelemetryName ?? flag.Key; - var value = executionContext.UserInfo.FeatureFlags.Contains(flag.Key) ? "enabled" : "disabled"; + if (!featureFlag.TrackInTelemetry) continue; + var telemetryName = featureFlag.TelemetryName ?? featureFlag.Key; + var value = executionContext.UserInfo.FeatureFlags.Contains(featureFlag.Key) ? "enabled" : "disabled"; AddCustomProperty(telemetry, $"feature_{telemetryName}", value); } } diff --git a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs index f9994a0e6..7b22b623d 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs @@ -35,11 +35,11 @@ public void Apply() Activity.Current.SetTag("user.role", executionContext.UserInfo.Role); Activity.Current.SetTag("user.session_id", executionContext.UserInfo.SessionId?.Value); - foreach (var flag in FeatureFlags.FeatureFlags.GetAll()) + foreach (var featureFlag in FeatureFlags.FeatureFlags.GetAll()) { - if (!flag.TrackInTelemetry) continue; - var telemetryName = flag.TelemetryName ?? flag.Key; - var value = executionContext.UserInfo.FeatureFlags.Contains(flag.Key) ? "enabled" : "disabled"; + if (!featureFlag.TrackInTelemetry) continue; + var telemetryName = featureFlag.TelemetryName ?? featureFlag.Key; + var value = executionContext.UserInfo.FeatureFlags.Contains(featureFlag.Key) ? "enabled" : "disabled"; Activity.Current.SetTag($"feature_{telemetryName}", value); } } From bbd07c88746501ea1bd235497285a753869760ca Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 11 May 2026 14:06:36 +0200 Subject: [PATCH 046/155] Add group subtitles and center 64rem layout to back-office feature flags pages --- .../routes/feature-flags/$flagKey.tsx | 1 + .../BackOffice/routes/feature-flags/index.tsx | 25 +++- .../shared/translations/locale/da-DK.po | 110 ++++++++++++------ 3 files changed, 101 insertions(+), 35 deletions(-) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 7b716a7be..5c3c92395 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -55,6 +55,7 @@ export default function FeatureFlagDetailPage() { - + {isLoading ? : } @@ -83,6 +88,9 @@ function FeatureFlagGroupList({ groups }: Readonly<{ groups: FeatureFlagGroup[] return (

{group.label}

+

+ +

@@ -156,6 +164,21 @@ function FeatureFlagGroupList({ groups }: Readonly<{ groups: FeatureFlagGroup[] ); } +function FeatureFlagGroupSubtitle({ groupKey }: Readonly<{ groupKey: FeatureFlagGroupKey }>) { + switch (groupKey) { + case "Tenant": + return Per-tenant flags. Owners can toggle configurable flags. Admins control A/B rollouts.; + case "Plan": + return Gated by subscription plan and recomputed when the plan changes. Configured only in code.; + case "User": + return Per-user flags. Users can toggle configurable flags. Admins control A/B rollouts.; + case "System": + return ( + Platform-wide capabilities set at deployment via environment variables. Configured only in code. + ); + } +} + function FeatureFlagsSkeleton() { return (
diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index ad79bdae2..c9a56f1e2 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -170,7 +170,7 @@ msgstr "Alle hændelsestyper" msgid "All statuses" msgstr "Alle statusser" -msgid "All users across every account, newest first. Search and filter to narrow down." +msgid "All users across every account, most recently seen first. Search and filter to narrow down." msgstr "" msgid "All-time" @@ -189,8 +189,8 @@ msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." #. placeholder {0}: result.billingEventsAppended -#. placeholder {1}: formatDate(result.syncedAt) -msgid "Appended {0} new billing events. Last synced at {1}." +#. placeholder {1}: formatDate(result.reconciledAt) +msgid "Appended {0} new billing events. Last reconciled at {1}." msgstr "" msgid "Authoritative log of subscription, payment, and billing transitions across all accounts." @@ -199,18 +199,24 @@ msgstr "" msgid "Back Office" msgstr "Back Office" -msgid "Back to feature flags" -msgstr "" - msgid "Back Office - Localhost" msgstr "Back Office - Localhost" msgid "Back Office overview · {today}" msgstr "Back Office oversigt · {today}" +msgid "Back to feature flags" +msgstr "" + msgid "Basis" msgstr "Basis" +msgid "Beta features" +msgstr "Beta-funktioner" + +msgid "Billing" +msgstr "Fakturering" + msgid "Billing address" msgstr "Faktureringsadresse" @@ -235,6 +241,9 @@ msgstr "Browser" msgid "Bucket" msgstr "Bucket" +msgid "Cancel" +msgstr "" + msgid "Canceled" msgstr "Opsagt" @@ -277,6 +286,9 @@ msgstr "Luk kontoforhåndsvisning" msgid "Coming soon" msgstr "Kommer snart" +msgid "Compact view" +msgstr "Kompakt visning" + msgid "Contact your administrator." msgstr "Kontakt din administrator." @@ -300,6 +312,15 @@ msgstr "Aktuel periode" msgid "Current plan" msgstr "Aktuelt abonnement" +msgid "Custom branding" +msgstr "Tilpasset branding" + +msgid "Customer" +msgstr "Kunde" + +msgid "Customize the login page with your organization's logo and colors" +msgstr "Tilpas login-siden med din organisations logo og farver" + msgid "Dark" msgstr "Mørk" @@ -396,6 +417,9 @@ msgstr "Hændelsesvisning" msgid "Every invoice, refund, and credit note — the money in and out for this subscription." msgstr "" +msgid "Every invoice, refund, and credit note across all accounts." +msgstr "" + msgid "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." msgstr "" @@ -408,6 +432,9 @@ msgstr "" msgid "Expired" msgstr "" +msgid "Expires" +msgstr "" + msgid "Failed" msgstr "Mislykket" @@ -423,15 +450,21 @@ msgstr "" msgid "Feature flags" msgstr "Feature flags" +msgid "Feature flags are defined in code. This view controls their activation and rollout." +msgstr "Feature flags er defineret i koden. Dette view styrer deres aktivering og udrulning." + msgid "Filter by flag type" -msgstr "" +msgstr "Filtrér efter flag-type" msgid "Flag name" -msgstr "" +msgstr "Flagnavn" msgid "Free" msgstr "Gratis" +msgid "Gated by subscription plan and recomputed when the plan changes. Configured only in code." +msgstr "Begrænset af abonnementet og genberegnet når abonnementet ændres. Konfigureres kun i koden." + msgid "Go to home" msgstr "Gå til forsiden" @@ -453,6 +486,9 @@ msgstr "Fakturavisning" msgid "Invoices" msgstr "" +msgid "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." +msgstr "" + msgid "IP address" msgstr "IP-adresse" @@ -519,9 +555,6 @@ msgstr "Logo" msgid "Main navigation" msgstr "Hovednavigation" -msgid "Manage feature flags across the platform." -msgstr "" - msgid "Manual override" msgstr "" @@ -630,6 +663,12 @@ msgstr "Intet abonnement" msgid "No recent billing events" msgstr "" +msgid "No recent logins" +msgstr "" + +msgid "No recent payments" +msgstr "" + msgid "No recent signups" msgstr "Ingen nylige tilmeldinger" @@ -687,6 +726,9 @@ msgstr "Åbn konto" msgid "Open credit note" msgstr "Åbn kreditnota" +msgid "Open in Stripe" +msgstr "" + msgid "Open invoice" msgstr "Åbn faktura" @@ -731,6 +773,9 @@ msgstr "Forfalden" msgid "Payment failed" msgstr "Betaling mislykkedes" +msgid "Payment method" +msgstr "" + msgid "Payment method updated" msgstr "" @@ -746,6 +791,12 @@ msgstr "Afventer" msgid "per month, billed monthly" msgstr "pr. måned, faktureres månedligt" +msgid "Per-tenant flags. Owners can toggle configurable flags. Admins control A/B rollouts." +msgstr "Tenant-flag. Ejere kan slå konfigurerbare flag til og fra. Admins styrer A/B-udrulninger." + +msgid "Per-user flags. Users can toggle configurable flags. Admins control A/B rollouts." +msgstr "Bruger-flag. Brugere kan slå konfigurerbare flag til og fra. Admins styrer A/B-udrulninger." + msgid "Period" msgstr "Periode" @@ -767,6 +818,12 @@ msgstr "" msgid "Plan transition" msgstr "" +msgid "Platform" +msgstr "" + +msgid "Platform-wide capabilities set at deployment via environment variables. Configured only in code." +msgstr "Platformomspændende funktioner sat ved udrulning via miljøvariabler. Konfigureres kun i koden." + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -791,6 +848,12 @@ msgstr "" msgid "Recent billing events" msgstr "" +msgid "Recent logins" +msgstr "" + +msgid "Recent payments" +msgstr "" + msgid "Recent signups" msgstr "Nylige tilmeldinger" @@ -1027,11 +1090,8 @@ msgstr "Subtotal" msgid "Succeeded" msgstr "Gennemført" -msgid "Support" -msgstr "Support" - -msgid "Support (coming soon)" -msgstr "Support (kommer snart)" +msgid "Successful logins will appear here as users sign in." +msgstr "" msgid "Successful, pending, and failed invoices across all accounts." msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle konti." @@ -1039,18 +1099,6 @@ msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle kont msgid "Suspended" msgstr "Suspenderet" -msgid "Sync complete" -msgstr "" - -msgid "Sync complete with drift detected" -msgstr "" - -msgid "Sync with Stripe" -msgstr "" - -msgid "Syncing..." -msgstr "" - msgid "System" msgstr "System" @@ -1193,12 +1241,6 @@ msgstr "" msgid "vs prior period" msgstr "mod forrige periode" -msgid "Wait list" -msgstr "Venteliste" - -msgid "Wait list (coming soon)" -msgstr "Venteliste (kommer snart)" - msgid "When" msgstr "Hvornår" From db1ce71885f9b9ef4704810d64bc90f69f56e756 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 11 May 2026 15:18:09 +0200 Subject: [PATCH 047/155] Fix tenant override persistence and switch flicker on back-office feature flag detail --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 3 +- .../Api/Endpoints/FeatureFlagEndpoints.cs | 3 +- .../-components/TenantOverrideRow.tsx | 18 ++++---- .../-components/UserOverrideRow.tsx | 14 +++--- .../RemoveTenantFeatureFlagOverride.cs | 6 +-- .../Commands/SetTenantFeatureFlagInternal.cs | 12 ++--- .../Tests/FeatureFlags/FeatureFlagTests.cs | 44 +++++++++---------- 7 files changed, 49 insertions(+), 51 deletions(-) diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 862147eb6..24a4a4053 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using SharedKernel.ApiResults; using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Domain; using SharedKernel.Endpoints; using SharedKernel.OpenApi; @@ -47,7 +48,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); - group.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, long tenantId, IMediator mediator) + group.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, TenantId tenantId, IMediator mediator) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index ed9f0f823..1dcb9b2b5 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -1,6 +1,7 @@ using Account.Features.FeatureFlags.Commands; using Account.Features.FeatureFlags.Queries; using SharedKernel.ApiResults; +using SharedKernel.Domain; using SharedKernel.Endpoints; using SharedKernel.OpenApi; @@ -37,7 +38,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); - internalGroup.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, long tenantId, IMediator mediator) + internalGroup.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, TenantId tenantId, IMediator mediator) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index 19c2dc7b2..f0c4f2c8a 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -45,21 +45,19 @@ export function TenantOverrideRow({ const removeMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}/tenant-override"); useEffect(() => { - if (!overrideMutation.isPending) { - setOptimisticEnabled(tenant.isEnabled); - } - }, [tenant.isEnabled, overrideMutation.isPending]); + setOptimisticEnabled(tenant.isEnabled); + }, [tenant.isEnabled]); const handleToggle = (checked: boolean) => { setOptimisticEnabled(checked); overrideMutation.mutate( { params: { path: { flagKey } }, - body: { tenantId: Number(tenant.tenantId), enabled: checked } + body: { tenantId: tenant.tenantId, enabled: checked } }, { - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] }); const message = checked @@ -77,11 +75,11 @@ export function TenantOverrideRow({ const handleRemoveOverride = () => { removeMutation.mutate( { - params: { path: { flagKey }, query: { tenantId: Number(tenant.tenantId) } } + params: { path: { flagKey }, query: { tenantId: tenant.tenantId } } }, { - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] }); toast.success(t`Override removed for ${tenant.tenantName}`); diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index ae8620444..afd674f08 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -65,18 +65,16 @@ export function UserOverrideRow({ }); useEffect(() => { - if (!overrideMutation.isPending) { - setOptimisticEnabled(user.isEnabled); - } - }, [user.isEnabled, overrideMutation.isPending]); + setOptimisticEnabled(user.isEnabled); + }, [user.isEnabled]); const handleToggle = (checked: boolean) => { setOptimisticEnabled(checked); overrideMutation.mutate( { enabled: checked }, { - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] }); const message = checked @@ -93,8 +91,8 @@ export function UserOverrideRow({ const handleRemoveOverride = () => { removeMutation.mutate(undefined, { - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] }); toast.success(t`Override removed for ${user.email}`); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs index e556b5465..d235c1af1 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -15,7 +15,7 @@ public sealed record RemoveTenantFeatureFlagOverrideCommand : ICommand, IRequest [JsonIgnore] // Removes from API contract public string FlagKey { get; init; } = null!; - public required long TenantId { get; init; } + public required TenantId TenantId { get; init; } } public sealed class RemoveTenantFeatureFlagOverrideValidator : AbstractValidator @@ -34,12 +34,12 @@ public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepositor { public async Task Handle(RemoveTenantFeatureFlagOverrideCommand command, CancellationToken cancellationToken) { - var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, null, cancellationToken); if (tenantOverride is null) return Result.NotFound($"No tenant override found for flag '{command.FlagKey}' and tenant '{command.TenantId}'."); featureFlagRepository.Remove(tenantOverride); - var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); if (tenant is not null) { tenant.IncrementFeatureFlagVersion(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 4ce7a826b..f4dd0c2ab 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -15,7 +15,7 @@ public sealed record SetTenantFeatureFlagInternalCommand : ICommand, IRequest Handle(SetTenantFeatureFlagInternalCommand command, Ca if (command.Enabled) { - var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, null, cancellationToken); if (tenantOverride is null) { - tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId); + tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId.Value); tenantOverride.Activate(now); await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); } @@ -57,10 +57,10 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca } else { - var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, null, cancellationToken); if (tenantOverride is null) { - tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId); + tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId.Value); await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); } else @@ -72,7 +72,7 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); } - var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); if (tenant is not null) { tenant.IncrementFeatureFlagVersion(); diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index b30af56fd..fa5d38220 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -145,7 +145,7 @@ public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideR { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; // Act @@ -156,12 +156,12 @@ public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideR var rowCount = Connection.ExecuteScalar( "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId }] + [new { flagKey, tenantId = tenantId.Value }] ); rowCount.Should().Be(1); var enabledAt = Connection.ExecuteScalar( "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId }] + [new { flagKey, tenantId = tenantId.Value }] ); enabledAt.Should().NotBeNullOrEmpty(); @@ -175,7 +175,7 @@ public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverrid { // Arrange - tenant has no override row (enabled via A/B rollout or default) var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = false }; // Act @@ -186,12 +186,12 @@ public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverrid var rowCount = Connection.ExecuteScalar( "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId }] + [new { flagKey, tenantId = tenantId.Value }] ); rowCount.Should().Be(1); var disabledAt = Connection.ExecuteScalar( "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId }] + [new { flagKey, tenantId = tenantId.Value }] ); disabledAt.Should().BeNull("newly created disabled override should not have disabled_at set when never activated"); @@ -205,7 +205,7 @@ public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_Shou { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; // Act @@ -226,7 +226,7 @@ public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDelet { // Arrange - create an override row first var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var overrideId = FeatureFlagId.NewId().ToString(); Connection.Insert("feature_flags", [ ("id", overrideId), @@ -253,7 +253,7 @@ public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDelet var rowCount = Connection.ExecuteScalar( "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId }] + [new { flagKey, tenantId = tenantId.Value }] ); rowCount.Should().Be(0); @@ -267,7 +267,7 @@ public async Task RemoveTenantFeatureFlagOverride_WhenNoOverrideExists_ShouldRet { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; // Act var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); @@ -392,7 +392,7 @@ public async Task SetUserFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow // Arrange var flagKey = "compact-view"; var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetUserFeatureFlagInternalCommand { UserId = userId, TenantId = tenantId, Enabled = true }; // Act @@ -418,7 +418,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenOverrideExists_ShouldDeleteR // Arrange var flagKey = "compact-view"; var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var overrideId = FeatureFlagId.NewId().ToString(); Connection.Insert("feature_flags", [ ("id", overrideId), @@ -460,7 +460,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldRetur // Arrange var flagKey = "compact-view"; var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; // Act var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); @@ -508,7 +508,7 @@ public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithMa // Arrange var flagKey = "compact-view"; var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var overrideId = FeatureFlagId.NewId().ToString(); Connection.Insert("feature_flags", [ ("id", overrideId), @@ -758,7 +758,7 @@ public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManual { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var overrideId = FeatureFlagId.NewId().ToString(); Connection.Insert("feature_flags", [ ("id", overrideId), @@ -823,7 +823,7 @@ public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRoll { // Arrange - set up A/B rollout at 100% so all tenants are enabled var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var baseRowId = Connection.ExecuteScalar( "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] ); @@ -995,7 +995,7 @@ public async Task ActivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatur { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] ); @@ -1017,7 +1017,7 @@ public async Task DeactivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeat { // Arrange var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] ); @@ -1039,7 +1039,7 @@ public async Task SetTenantFeatureFlagInternal_WhenCalled_ShouldIncrementTenantF { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] ); @@ -1062,7 +1062,7 @@ public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeat { // Arrange var flagKey = "custom-branding"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] ); @@ -1085,7 +1085,7 @@ public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlag { // Arrange var flagKey = "compact-view"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] ); @@ -1108,7 +1108,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllT { // Arrange var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id.Value; + var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] ); From c4ecee3b2479765435c229be5fe82314861d4690 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 11 May 2026 18:07:51 +0200 Subject: [PATCH 048/155] Enrich feature flag pages with rich account and user rendering and add feature flag tabs to detail pages --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 2 +- .../Api/BackOffice/TenantsEndpoints.cs | 5 + .../account/Api/BackOffice/UsersEndpoints.cs | 5 + .../Api/Endpoints/FeatureFlagEndpoints.cs | 2 +- .../BackOffice/routes/accounts/$tenantId.tsx | 14 +- .../-components/AccountFeatureFlagRow.tsx | 139 +++++++ .../-components/AccountFeatureFlagsTab.tsx | 134 +++++++ .../accounts/-components/AccountsTableRow.tsx | 48 +-- .../routes/accounts/-components/MrrCell.tsx | 49 +++ .../-components/PlanFeatureFlagSections.tsx | 28 +- .../-components/TenantOverrideRow.tsx | 86 +++-- .../-components/TenantOverrideTable.tsx | 71 ++++ .../-components/TenantOverridesSection.tsx | 93 +---- .../-components/UserOverrideRow.tsx | 113 +++--- .../-components/UserOverridesSection.tsx | 62 ++-- .../feature-flags/-components/flagLabels.ts | 15 + .../routes/feature-flags/-components/types.ts | 31 +- .../BackOffice/routes/users/$userId.tsx | 14 +- .../users/-components/UserFeatureFlagRow.tsx | 125 +++++++ .../-components/UserFeatureFlagsSection.tsx | 91 +++++ .../shared/translations/locale/da-DK.po | 342 ++++++++++-------- .../Commands/RemoveUserFeatureFlagOverride.cs | 10 +- .../Commands/SetUserFeatureFlagInternal.cs | 18 +- .../Domain/FeatureFlagRepository.cs | 18 + .../Queries/GetFeatureFlagTenants.cs | 86 ++++- .../Queries/GetFeatureFlagUsers.cs | 57 ++- .../Queries/GetTenantFeatureFlags.cs | 125 +++++++ .../Queries/GetUserFeatureFlags.cs | 124 +++++++ .../Tenants/BackOffice/Queries/GetTenants.cs | 72 ++-- .../Tests/FeatureFlags/FeatureFlagTests.cs | 44 +-- .../GetTenantFeatureFlagsTests.cs | 165 +++++++++ .../FeatureFlags/GetUserFeatureFlagsTests.cs | 138 +++++++ .../SubscriptionRepositoryDriftScopeTests.cs | 4 +- .../FeatureFlags/FeatureFlagScope.cs | 1 + 34 files changed, 1800 insertions(+), 531 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagsTab.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/MrrCell.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/TenantOverrideTable.tsx create mode 100644 application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx create mode 100644 application/account/BackOffice/routes/users/-components/UserFeatureFlagsSection.tsx create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs create mode 100644 application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs create mode 100644 application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs create mode 100644 application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 24a4a4053..5aead7339 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -60,7 +60,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); - group.MapDelete("/{flagKey}/user-override", async Task (string flagKey, string userId, long tenantId, IMediator mediator) + group.MapDelete("/{flagKey}/user-override", async Task (string flagKey, UserId userId, TenantId tenantId, IMediator mediator) => await mediator.Send(new RemoveUserFeatureFlagOverrideCommand { FlagKey = flagKey, UserId = userId, TenantId = tenantId }) ).DisableAntiforgery(); } diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs index bf59e2937..95882e6b1 100644 --- a/application/account/Api/BackOffice/TenantsEndpoints.cs +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -1,3 +1,4 @@ +using Account.Features.FeatureFlags.Queries; using Account.Features.Tenants.BackOffice.Commands; using Account.Features.Tenants.BackOffice.Queries; using Microsoft.Extensions.Options; @@ -48,6 +49,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(query with { Id = id }) ).Produces(); + group.MapGet("/{id}/feature-flags", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantFeatureFlagsQuery { TenantId = id }) + ).Produces(); + group.MapPost("/{id}/reconcile-with-stripe", async Task> (TenantId id, IMediator mediator) => await mediator.Send(new ReconcileTenantWithStripeCommand { TenantId = id }) ).Produces().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); diff --git a/application/account/Api/BackOffice/UsersEndpoints.cs b/application/account/Api/BackOffice/UsersEndpoints.cs index f2f2b6928..841524333 100644 --- a/application/account/Api/BackOffice/UsersEndpoints.cs +++ b/application/account/Api/BackOffice/UsersEndpoints.cs @@ -1,3 +1,4 @@ +using Account.Features.FeatureFlags.Queries; using Account.Features.Users.BackOffice.Queries; using Microsoft.Extensions.Options; using SharedKernel.ApiResults; @@ -38,5 +39,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapGet("/{id}/login-history", async Task> (UserId id, [AsParameters] GetBackOfficeUserLoginHistoryQuery query, IMediator mediator) => await mediator.Send(query with { Id = id }) ).Produces(); + + group.MapGet("/{id}/feature-flags", async Task> (UserId id, IMediator mediator) + => await mediator.Send(new GetUserFeatureFlagsQuery { UserId = id }) + ).Produces(); } } diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 1dcb9b2b5..68960fb79 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -50,7 +50,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); - internalGroup.MapDelete("/{flagKey}/user-override", async Task (string flagKey, string userId, long tenantId, IMediator mediator) + internalGroup.MapDelete("/{flagKey}/user-override", async Task (string flagKey, UserId userId, TenantId tenantId, IMediator mediator) => await mediator.Send(new RemoveUserFeatureFlagOverrideCommand { FlagKey = flagKey, UserId = userId, TenantId = tenantId }) ).DisableAntiforgery(); diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx index 5da040f4f..c5a2c385c 100644 --- a/application/account/BackOffice/routes/accounts/$tenantId.tsx +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -4,7 +4,7 @@ import { AppLayout } from "@repo/ui/components/AppLayout"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { ActivityIcon, LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; +import { ActivityIcon, FlagIcon, LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; import { useCallback } from "react"; import { z } from "zod"; @@ -14,16 +14,17 @@ import { api } from "@/shared/lib/api/client"; import { AccountBillingTab } from "./-components/AccountBillingTab"; import { AccountCurrentPlanCard } from "./-components/AccountCurrentPlanCard"; import { AccountDetailHeader } from "./-components/AccountDetailHeader"; +import { AccountFeatureFlagsTab } from "./-components/AccountFeatureFlagsTab"; import { AccountHealthTiles } from "./-components/AccountHealthTiles"; import { AccountOverviewTab } from "./-components/AccountOverviewTab"; import { AccountUsersTab } from "./-components/AccountUsersTab"; -type AccountDetailTab = "overview" | "users" | "invoices" | "billing-events"; +type AccountDetailTab = "overview" | "users" | "invoices" | "billing-events" | "feature-flags"; const isSubscriptionEnabled = import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; const accountDetailSearchSchema = z.object({ - tab: z.enum(["overview", "users", "invoices", "billing-events"]).optional() + tab: z.enum(["overview", "users", "invoices", "billing-events", "feature-flags"]).optional() }); export const Route = createFileRoute("/accounts/$tenantId")({ @@ -86,6 +87,10 @@ function AccountDetailPage() { Billing events )} + + + Feature flags + @@ -118,6 +123,9 @@ function AccountDetailPage() { )} + + +
diff --git a/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx new file mode 100644 index 000000000..41e9a4869 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx @@ -0,0 +1,139 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { Switch } from "@repo/ui/components/Switch"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { Link } from "@tanstack/react-router"; +import { XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, queryClient } from "@/shared/lib/api/client"; + +import { getFeatureFlagName, getFeatureFlagSourceLabel } from "../../feature-flags/-components/flagLabels"; +import { ScopeIcon } from "../../feature-flags/-components/ScopeIcon"; + +type TenantFeatureFlagInfo = components["schemas"]["TenantFeatureFlagInfo"]; + +export function AccountFeatureFlagRow({ + tenantId, + flag, + isPlanGroup +}: Readonly<{ tenantId: string; flag: TenantFeatureFlagInfo; isPlanGroup: boolean }>) { + const [optimisticEnabled, setOptimisticEnabled] = useState(flag.isEnabled); + const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + const removeMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + + useEffect(() => { + setOptimisticEnabled(flag.isEnabled); + }, [flag.isEnabled]); + + const flagName = getFeatureFlagName(flag.flagKey); + + const handleToggle = (checked: boolean) => { + setOptimisticEnabled(checked); + overrideMutation.mutate( + { + params: { path: { flagKey: flag.flagKey } }, + body: { tenantId, enabled: checked } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/tenants/{id}/feature-flags"] + }); + const message = checked ? t`${flagName} enabled` : t`${flagName} disabled`; + toast.success(message); + }, + onError: () => { + setOptimisticEnabled(flag.isEnabled); + } + } + ); + }; + + const handleRemoveOverride = () => { + removeMutation.mutate( + { + params: { path: { flagKey: flag.flagKey }, query: { tenantId } } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/tenants/{id}/feature-flags"] + }); + toast.success(t`Override removed for ${flagName}`); + } + } + ); + }; + + const isPending = overrideMutation.isPending || removeMutation.isPending; + + return ( + + + + + {flagName} + + + {isPlanGroup ? ( + + {flag.requiredPlan} + + ) : ( + {flag.rolloutBucket} + )} + + {getFeatureFlagSourceLabel(flag.source)} + + event.stopPropagation()}> + {isPlanGroup ? ( + + {flag.isEnabled ? Enabled : Disabled} + + ) : ( +
+ {flag.source === "manual_override" && ( + + + } + > + + + + Remove override + + + )} + +
+ )} +
+
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagsTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagsTab.tsx new file mode 100644 index 000000000..5a8a2bb60 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagsTab.tsx @@ -0,0 +1,134 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { useMemo } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; + +import { AccountFeatureFlagRow } from "./AccountFeatureFlagRow"; + +type TenantFeatureFlagInfo = components["schemas"]["TenantFeatureFlagInfo"]; + +interface AccountFeatureFlagsTabProps { + tenantId: string; +} + +export function AccountFeatureFlagsTab({ tenantId }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/tenants/{id}/feature-flags", { + params: { path: { id: tenantId } } + }); + + const { accountFlags, planFlags } = useMemo(() => { + const flags = data?.flags ?? []; + return { + accountFlags: flags.filter((f) => f.requiredPlan == null), + planFlags: flags.filter((f) => f.requiredPlan != null) + }; + }, [data?.flags]); + + if (isLoading) { + return ; + } + + if (accountFlags.length === 0 && planFlags.length === 0) { + return ( + + + + No feature flags + + + No tenant-scoped feature flags are defined for this account. + + + + ); + } + + return ( +
+ {accountFlags.length > 0 && ( + + )} + {planFlags.length > 0 && ( + + )} +
+ ); +} + +function FeatureFlagGroup({ + tenantId, + flags, + title, + description, + isPlanGroup +}: Readonly<{ + tenantId: string; + flags: TenantFeatureFlagInfo[]; + title: string; + description: string; + isPlanGroup: boolean; +}>) { + return ( +
+

{title}

+

{description}

+
+ + + + Name + + {isPlanGroup ? ( + + Required plan + + ) : ( + + Bucket + + )} + + Source + + + {isPlanGroup ? Status : Override} + + + + + {flags.map((flag) => ( + + ))} + +
+
+ ); +} + +function FeatureFlagsTabSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx index de14a75e9..fdca81e3a 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx @@ -2,27 +2,19 @@ import { Badge } from "@repo/ui/components/Badge"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; -import { formatCurrency } from "@repo/utils/currency/formatCurrency"; import type { components } from "@/shared/lib/api/client"; import { SmartDateTime } from "@/shared/components/SmartDateTime"; -import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; import { getUserDisplayName } from "../../users/-components/userDisplay"; +import { MrrCell } from "./MrrCell"; import { TenantStatusBadge } from "./TenantStatusBadge"; type TenantSummary = components["schemas"]["TenantSummary"]; -function formatMonthlyRevenue(amount: number | null, currency: string | null): string { - if (amount === null || currency === null) { - return "-"; - } - return formatCurrency(amount, currency); -} - export function AccountsTableRow({ tenant, formatDate @@ -59,7 +51,13 @@ export function AccountsTableRow({ />
- +
@@ -69,7 +67,12 @@ export function AccountsTableRow({ {getSubscriptionPlanLabel(tenant.plan)} - + {tenant.renewalDate ? formatDate(tenant.renewalDate) : -} @@ -107,26 +110,3 @@ export function AccountsTableRow({ ); } - -function MrrCell({ tenant, align = "start" }: Readonly<{ tenant: TenantSummary; align?: "start" | "end" }>) { - const currentAmount = formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency); - const isCanceling = tenant.plannedChange === PlannedSubscriptionChange.Cancellation; - const isDowngrading = tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; - const newAmount = - isCanceling && tenant.currency !== null - ? formatCurrency(0, tenant.currency) - : isDowngrading && tenant.scheduledPriceAmount !== null && tenant.currency !== null - ? formatCurrency(tenant.scheduledPriceAmount, tenant.currency) - : null; - - if (newAmount === null) { - return {currentAmount}; - } - - return ( -
- {currentAmount} - {newAmount} -
- ); -} diff --git a/application/account/BackOffice/routes/accounts/-components/MrrCell.tsx b/application/account/BackOffice/routes/accounts/-components/MrrCell.tsx new file mode 100644 index 000000000..c30ecc279 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/MrrCell.tsx @@ -0,0 +1,49 @@ +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; + +type PlannedSubscriptionChangeValue = components["schemas"]["PlannedSubscriptionChange"] | null; + +interface MrrCellProps { + monthlyRecurringRevenue: number | null; + scheduledPriceAmount: number | null; + currency: string | null; + plannedChange: PlannedSubscriptionChangeValue; + align?: "start" | "end"; +} + +function formatMonthlyRevenue(amount: number | null, currency: string | null): string { + if (amount === null || currency === null) return "-"; + return formatCurrency(amount, currency); +} + +export function MrrCell({ + monthlyRecurringRevenue, + scheduledPriceAmount, + currency, + plannedChange, + align = "start" +}: Readonly) { + const currentAmount = formatMonthlyRevenue(monthlyRecurringRevenue, currency); + const isCanceling = plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const newAmount = + isCanceling && currency !== null + ? formatCurrency(0, currency) + : isDowngrading && scheduledPriceAmount !== null && currency !== null + ? formatCurrency(scheduledPriceAmount, currency) + : null; + + if (newAmount === null) { + return {currentAmount}; + } + + return ( +
+ {currentAmount} + {newAmount} +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx b/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx index be2941abc..fc3ced23b 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Badge } from "@repo/ui/components/Badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/ui/components/Collapsible"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; import { ChevronDown } from "lucide-react"; @@ -40,9 +41,7 @@ export function PlanFeatureFlagTenantsSection({ tenants }: Readonly<{ tenants: F const filtered = useMemo(() => { const lowerSearch = search.toLowerCase(); return search - ? tenants.filter( - (tenant) => tenant.tenantName.toLowerCase().includes(lowerSearch) || tenant.tenantId.includes(lowerSearch) - ) + ? tenants.filter((tenant) => tenant.name.toLowerCase().includes(lowerSearch) || tenant.id.includes(lowerSearch)) : tenants; }, [tenants, search]); @@ -123,9 +122,9 @@ function PlanFeatureFlagTenantTable({ {tenants.map((tenant) => ( - - {tenant.tenantId} - {tenant.tenantName} + + {tenant.id} + {tenant.name} {tenant.plan} @@ -143,20 +142,17 @@ function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tena const [isOpen, setIsOpen] = useState(true); return ( -
- - {isOpen && } -
+ + + + + ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index f0c4f2c8a..ad932db51 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -1,31 +1,27 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; import { Button } from "@repo/ui/components/Button"; import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { Link } from "@tanstack/react-router"; import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { api, queryClient } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; import type { FeatureFlagTenantInfo } from "./types"; -function getSourceLabel(source: string): string { - switch (source) { - case "manual_override": - return t`Manual override`; - case "ab_rollout": - return t`A/B rollout`; - case "plan": - return t`Plan`; - case "default": - return t`Default`; - default: - return source; - } -} +import { MrrCell } from "../../accounts/-components/MrrCell"; +import { TenantStatusBadge } from "../../accounts/-components/TenantStatusBadge"; +import { getUserDisplayName } from "../../users/-components/userDisplay"; +import { getFeatureFlagSourceLabel } from "./flagLabels"; export function TenantOverrideRow({ flagKey, @@ -43,6 +39,7 @@ export function TenantOverrideRow({ const [optimisticEnabled, setOptimisticEnabled] = useState(tenant.isEnabled); const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/tenant-override"); const removeMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}/tenant-override"); + const formatDate = useFormatDate(); useEffect(() => { setOptimisticEnabled(tenant.isEnabled); @@ -53,7 +50,7 @@ export function TenantOverrideRow({ overrideMutation.mutate( { params: { path: { flagKey } }, - body: { tenantId: tenant.tenantId, enabled: checked } + body: { tenantId: tenant.id, enabled: checked } }, { onSuccess: async () => { @@ -61,8 +58,8 @@ export function TenantOverrideRow({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] }); const message = checked - ? t`${featureFlagDescription} enabled for ${tenant.tenantName}` - : t`${featureFlagDescription} disabled for ${tenant.tenantName}`; + ? t`${featureFlagDescription} enabled for ${tenant.name}` + : t`${featureFlagDescription} disabled for ${tenant.name}`; toast.success(message); }, onError: () => { @@ -75,33 +72,68 @@ export function TenantOverrideRow({ const handleRemoveOverride = () => { removeMutation.mutate( { - params: { path: { flagKey }, query: { tenantId: tenant.tenantId } } + params: { path: { flagKey }, query: { tenantId: tenant.id } } }, { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/tenants"] }); - toast.success(t`Override removed for ${tenant.tenantName}`); + toast.success(t`Override removed for ${tenant.name}`); } } ); }; const isPending = overrideMutation.isPending || removeMutation.isPending; + const ownerLabel = tenant.owner + ? getUserDisplayName(tenant.owner.firstName, tenant.owner.lastName, tenant.owner.email) + : null; return ( - - {tenant.tenantId} - {tenant.tenantName} - {tenant.plan} + + + + +
+ {tenant.name} + {ownerLabel && {ownerLabel}} +
+ +
+ + {getSubscriptionPlanLabel(tenant.plan)} + + + + + + {tenant.renewalDate ? formatDate(tenant.renewalDate) : -} + + + + - {getSourceLabel(tenant.source)} + {getFeatureFlagSourceLabel(tenant.source)} {showRolloutBucket && ( {tenant.rolloutBucket} )} - + event.stopPropagation()}>
{tenant.source === "manual_override" && ( @@ -113,7 +145,7 @@ export function TenantOverrideRow({ className="size-7" onClick={handleRemoveOverride} disabled={isPending} - aria-label={t`Remove override for ${tenant.tenantName}`} + aria-label={t`Remove override for ${tenant.name}`} /> } > @@ -129,7 +161,7 @@ export function TenantOverrideRow({ onCheckedChange={handleToggle} disabled={isPending} className={!isFeatureFlagActive && optimisticEnabled ? "opacity-50" : ""} - aria-label={t`Override for ${tenant.tenantName}`} + aria-label={t`Override for ${tenant.name}`} />
diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideTable.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideTable.tsx new file mode 100644 index 000000000..ff069ee35 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideTable.tsx @@ -0,0 +1,71 @@ +import { Trans } from "@lingui/react/macro"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; + +import type { FeatureFlagTenantInfo } from "./types"; + +import { TenantOverrideRow } from "./TenantOverrideRow"; + +export interface TenantOverrideTableProps { + ariaLabel: string; + tenants: FeatureFlagTenantInfo[]; + flagKey: string; + featureFlagDescription: string; + showRolloutBucket: boolean; + isFeatureFlagActive: boolean; +} + +export function TenantOverrideTable({ + ariaLabel, + tenants, + flagKey, + featureFlagDescription, + showRolloutBucket, + isFeatureFlagActive +}: Readonly) { + return ( + + + + + Account + + + Plan + + + MRR + + + Renewal + + + Status + + + Source + + {showRolloutBucket && ( + + Bucket + + )} + + Override + + + + + {tenants.map((tenant) => ( + + ))} + +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index 43c09c513..d8a36f53d 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/ui/components/Collapsible"; import { TextField } from "@repo/ui/components/TextField"; import { ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; @@ -9,7 +9,7 @@ import type { RolloutBucketRange } from "./rolloutBucket"; import type { FeatureFlagTenantInfo } from "./types"; import { sortBySourceThenRolloutBucket } from "./rolloutBucket"; -import { TenantOverrideRow } from "./TenantOverrideRow"; +import { TenantOverrideTable, type TenantOverrideTableProps } from "./TenantOverrideTable"; export function TenantOverridesSection({ flagKey, @@ -31,9 +31,7 @@ export function TenantOverridesSection({ const filtered = useMemo(() => { const lowerSearch = search.toLowerCase(); return search - ? tenants.filter( - (tenant) => tenant.tenantName.toLowerCase().includes(lowerSearch) || tenant.tenantId.includes(lowerSearch) - ) + ? tenants.filter((tenant) => tenant.name.toLowerCase().includes(lowerSearch) || tenant.id.includes(lowerSearch)) : tenants; }, [tenants, search]); @@ -88,7 +86,7 @@ export function TenantOverridesSection({ className="max-w-[20rem]" /> {isSearching ? ( - ) { - return ( - - - - - Account ID - - - Account - - - Plan - - - Source - - {showRolloutBucket && ( - - Bucket - - )} - - Override - - - - - {tenants.map((tenant) => ( - - ))} - -
- ); -} - function CollapsibleTenantGroup({ label, ...tableProps -}: Readonly<{ label: string } & Omit>) { +}: Readonly<{ label: string } & Omit>) { const [isOpen, setIsOpen] = useState(true); return ( -
- - {isOpen && - (tableProps.tenants.length > 0 ? ( - + + + {tableProps.tenants.length > 0 ? ( + ) : (

No accounts in this group.

- ))} -
+ )} + + ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index afd674f08..3c6de689c 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -1,32 +1,24 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; import { Button } from "@repo/ui/components/Button"; import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; -import { useMutation } from "@tanstack/react-query"; -import { XIcon } from "lucide-react"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { Link } from "@tanstack/react-router"; +import { MailIcon, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { apiClient, queryClient } from "@/shared/lib/api/client"; +import { api, queryClient } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; import type { FeatureFlagUserInfo } from "./types"; -function getSourceLabel(source: string): string { - switch (source) { - case "manual_override": - return t`Manual override`; - case "ab_rollout": - return t`A/B rollout`; - case "plan": - return t`Plan`; - case "default": - return t`Default`; - default: - return source; - } -} +import { getUserDisplayName, getUserInitials } from "../../users/-components/userDisplay"; +import { getFeatureFlagSourceLabel } from "./flagLabels"; export function UserOverrideRow({ flagKey, @@ -42,27 +34,9 @@ export function UserOverrideRow({ isFeatureFlagActive: boolean; }>) { const [optimisticEnabled, setOptimisticEnabled] = useState(user.isEnabled); - - const overrideMutation = useMutation({ - mutationFn: async (vars: { enabled: boolean }) => { - // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec - const { error } = await apiClient.PUT("/api/back-office/feature-flags/{flagKey}/user-override" as any, { - params: { path: { flagKey } }, - body: { userId: user.userId, tenantId: user.tenantId, enabled: vars.enabled } - }); - if (error) throw error; - } - }); - - const removeMutation = useMutation({ - mutationFn: async () => { - // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec - const { error } = await apiClient.DELETE("/api/back-office/feature-flags/{flagKey}/user-override" as any, { - params: { path: { flagKey }, query: { userId: user.userId, tenantId: user.tenantId } } - }); - if (error) throw error; - } - }); + const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/user-override"); + const removeMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}/user-override"); + const formatDate = useFormatDate(); useEffect(() => { setOptimisticEnabled(user.isEnabled); @@ -71,7 +45,10 @@ export function UserOverrideRow({ const handleToggle = (checked: boolean) => { setOptimisticEnabled(checked); overrideMutation.mutate( - { enabled: checked }, + { + params: { path: { flagKey } }, + body: { userId: user.id, tenantId: user.tenantId, enabled: checked } + }, { onSuccess: async () => { await queryClient.invalidateQueries({ @@ -90,29 +67,63 @@ export function UserOverrideRow({ }; const handleRemoveOverride = () => { - removeMutation.mutate(undefined, { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] - }); - toast.success(t`Override removed for ${user.email}`); + removeMutation.mutate( + { + params: { path: { flagKey }, query: { userId: user.id, tenantId: user.tenantId } } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users"] + }); + toast.success(t`Override removed for ${user.email}`); + } } - }); + ); }; const isPending = overrideMutation.isPending || removeMutation.isPending; + const displayName = getUserDisplayName(user.firstName, user.lastName, user.email); + const initials = getUserInitials(user.firstName, user.lastName, user.email); return ( - - {user.email} - {user.tenantName} + + + + + {user.avatarUrl && } + {initials} + +
+ {displayName} + + + {user.email} + +
+ +
+ + {user.tenantName} + + + {getUserRoleLabel(user.role)} + + + {user.lastSeenAt ? formatDate(user.lastSeenAt, true, true) : -} + - {getSourceLabel(user.source)} + {getFeatureFlagSourceLabel(user.source)} {showRolloutBucket && ( {user.rolloutBucket} )} - + event.stopPropagation()}>
{user.source === "manual_override" && ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 4c55519fc..6ff8ed6e2 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -1,15 +1,15 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/ui/components/Collapsible"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; -import { useQuery } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { apiClient } from "@/shared/lib/api/client"; +import { api } from "@/shared/lib/api/client"; import type { RolloutBucketRange } from "./rolloutBucket"; -import type { FeatureFlagUserInfo, GetFeatureFlagUsersResponse } from "./types"; +import type { FeatureFlagUserInfo } from "./types"; import { sortBySourceThenRolloutBucket } from "./rolloutBucket"; import { UserEmptyState } from "./UserEmptyState"; @@ -36,17 +36,14 @@ export function UserOverridesSection({ return () => clearTimeout(timer); }, [search]); - const { data: usersData, isLoading } = useQuery({ - queryKey: ["get", "/api/back-office/feature-flags/{flagKey}/users", { flagKey, search: debouncedSearch }], - queryFn: async () => { - // oxlint-disable-next-line typescript-eslint/no-explicit-any -- endpoint not yet in OpenAPI spec - const { data } = await apiClient.GET("/api/back-office/feature-flags/{flagKey}/users" as any, { - params: { path: { flagKey }, query: { search: debouncedSearch } } - }); - return data as GetFeatureFlagUsersResponse | undefined; + const { data: usersData, isLoading } = api.useQuery( + "get", + "/api/back-office/feature-flags/{flagKey}/users", + { + params: { path: { flagKey }, query: { search: debouncedSearch } } }, - enabled: debouncedSearch.length > 0 - }); + { enabled: debouncedSearch.length > 0 } + ); const { enabledUsers, disabledUsers } = useMemo(() => { const all = usersData?.users ?? []; @@ -144,24 +141,30 @@ function UserTable({ isFeatureFlagActive }: Readonly) { return ( - +
- - Email + + User - + Account - + + Role + + + Last seen + + Source {showRolloutBucket && ( - + Bucket )} - + Override @@ -169,7 +172,7 @@ function UserTable({ {users.map((user) => ( - - {isOpen && } - + + + + + ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts index 230fb4109..a183f690e 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts @@ -50,3 +50,18 @@ export function getFeatureFlagName(flagKey: string): string { export function getFeatureFlagDescription(flagKey: string): string { return getKnownFeatureFlagLabels()[flagKey]?.description ?? ""; } + +export function getFeatureFlagSourceLabel(source: string): string { + switch (source) { + case "manual_override": + return t`Manual override`; + case "ab_rollout": + return t`A/B rollout`; + case "plan": + return t`Plan`; + case "default": + return t`Default`; + default: + return source; + } +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 7caede917..65ad035c0 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -1,3 +1,5 @@ +import type { components } from "@/shared/lib/api/client"; + export type FeatureFlagScope = "System" | "Tenant" | "User"; export interface FeatureFlagInfo { @@ -22,29 +24,12 @@ export interface GetFeatureFlagsResponse { flags: FeatureFlagInfo[]; } -export interface FeatureFlagTenantInfo { - tenantId: string; - tenantName: string; - plan: string; - isEnabled: boolean; - source: "manual_override" | "ab_rollout" | "plan" | "default"; - rolloutBucket: number; -} +export type FeatureFlagTenantInfo = components["schemas"]["FeatureFlagTenantInfo"]; -export interface GetFeatureFlagTenantsResponse { - tenants: FeatureFlagTenantInfo[]; -} +export type GetFeatureFlagTenantsResponse = components["schemas"]["GetFeatureFlagTenantsResponse"]; -export interface FeatureFlagUserInfo { - userId: string; - tenantId: string; - email: string; - tenantName: string; - isEnabled: boolean; - source: "manual_override" | "ab_rollout" | "plan" | "default"; - rolloutBucket: number; -} +export type FeatureFlagUserInfo = components["schemas"]["FeatureFlagUserInfo"]; -export interface GetFeatureFlagUsersResponse { - users: FeatureFlagUserInfo[]; -} +export type GetFeatureFlagUsersResponse = components["schemas"]["GetFeatureFlagUsersResponse"]; + +export type FeatureFlagSourceLiteral = "manual_override" | "ab_rollout" | "plan" | "default"; diff --git a/application/account/BackOffice/routes/users/$userId.tsx b/application/account/BackOffice/routes/users/$userId.tsx index 5c5ae99c8..bb79acb22 100644 --- a/application/account/BackOffice/routes/users/$userId.tsx +++ b/application/account/BackOffice/routes/users/$userId.tsx @@ -4,7 +4,7 @@ import { AppLayout } from "@repo/ui/components/AppLayout"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { Building2Icon, KeyIcon, MonitorIcon } from "lucide-react"; +import { Building2Icon, FlagIcon, KeyIcon, MonitorIcon } from "lucide-react"; import { useCallback } from "react"; import { z } from "zod"; @@ -14,14 +14,15 @@ import { api } from "@/shared/lib/api/client"; import { UserActivityTiles } from "./-components/UserActivityTiles"; import { UserDetailHeader } from "./-components/UserDetailHeader"; import { getUserDisplayName } from "./-components/userDisplay"; +import { UserFeatureFlagsSection } from "./-components/UserFeatureFlagsSection"; import { UserLoginHistorySection } from "./-components/UserLoginHistorySection"; import { UserSessionsSection } from "./-components/UserSessionsSection"; import { UserTenantsSection } from "./-components/UserTenantsSection"; -type UserDetailTab = "overview" | "sessions" | "logins"; +type UserDetailTab = "overview" | "sessions" | "logins" | "feature-flags"; const userDetailSearchSchema = z.object({ - tab: z.enum(["overview", "sessions", "logins"]).optional() + tab: z.enum(["overview", "sessions", "logins", "feature-flags"]).optional() }); export const Route = createFileRoute("/users/$userId")({ @@ -77,6 +78,10 @@ function UserDetailPage() { Sessions + + + Feature flags + @@ -87,6 +92,9 @@ function UserDetailPage() { + + + diff --git a/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx b/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx new file mode 100644 index 000000000..fd3b94006 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx @@ -0,0 +1,125 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Switch } from "@repo/ui/components/Switch"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { Link } from "@tanstack/react-router"; +import { XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, queryClient } from "@/shared/lib/api/client"; + +import { getFeatureFlagName, getFeatureFlagSourceLabel } from "../../feature-flags/-components/flagLabels"; +import { ScopeIcon } from "../../feature-flags/-components/ScopeIcon"; + +type UserFeatureFlagInfo = components["schemas"]["UserFeatureFlagInfo"]; + +export function UserFeatureFlagRow({ userId, flag }: Readonly<{ userId: string; flag: UserFeatureFlagInfo }>) { + const [optimisticEnabled, setOptimisticEnabled] = useState(flag.isEnabled); + const overrideMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/user-override"); + const removeMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}/user-override"); + + useEffect(() => { + setOptimisticEnabled(flag.isEnabled); + }, [flag.isEnabled]); + + const flagName = getFeatureFlagName(flag.flagKey); + + const handleToggle = (checked: boolean) => { + setOptimisticEnabled(checked); + overrideMutation.mutate( + { + params: { path: { flagKey: flag.flagKey } }, + body: { userId, tenantId: flag.tenantId, enabled: checked } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/users/{id}/feature-flags"] + }); + const message = checked ? t`${flagName} enabled` : t`${flagName} disabled`; + toast.success(message); + }, + onError: () => { + setOptimisticEnabled(flag.isEnabled); + } + } + ); + }; + + const handleRemoveOverride = () => { + removeMutation.mutate( + { + params: { + path: { flagKey: flag.flagKey }, + query: { userId, tenantId: flag.tenantId } + } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["get", "/api/back-office/users/{id}/feature-flags"] + }); + toast.success(t`Override removed for ${flagName}`); + } + } + ); + }; + + const isPending = overrideMutation.isPending || removeMutation.isPending; + + return ( + + + + + {flagName} + + + {flag.rolloutBucket} + + {getFeatureFlagSourceLabel(flag.source)} + + event.stopPropagation()}> +
+ {flag.source === "manual_override" && ( + + + } + > + + + + Remove override + + + )} + +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserFeatureFlagsSection.tsx b/application/account/BackOffice/routes/users/-components/UserFeatureFlagsSection.tsx new file mode 100644 index 000000000..7b06aa9bc --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserFeatureFlagsSection.tsx @@ -0,0 +1,91 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; + +import { api } from "@/shared/lib/api/client"; + +import { UserFeatureFlagRow } from "./UserFeatureFlagRow"; + +interface UserFeatureFlagsSectionProps { + userId: string; +} + +export function UserFeatureFlagsSection({ userId }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/users/{id}/feature-flags", { + params: { path: { id: userId } } + }); + + if (isLoading) { + return ; + } + + const flags = data?.flags ?? []; + + if (flags.length === 0) { + return ( +
+

+ Feature flags +

+ + + + No feature flags + + + No user-scoped feature flags are defined. + + + +
+ ); + } + + return ( +
+
+

+ Feature flags +

+

+ Per-user flags. Toggle the override switch to enable or disable for this user. +

+
+
+ + + + Name + + + Bucket + + + Source + + + Override + + + + + {flags.map((flag) => ( + + ))} + +
+ + ); +} + +function UserFeatureFlagsSectionSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index c9a56f1e2..827ce9488 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -24,7 +24,7 @@ msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" #. placeholder {0}: group.plan #. placeholder {1}: group.tenants.length msgid "{0} ({1})" -msgstr "" +msgstr "{0} ({1})" #. placeholder {0}: formatCurrency(blended, currency) msgid "{0} blended" @@ -37,25 +37,31 @@ msgstr "{0} i denne periode, eksklusiv moms" msgid "{activeUsers} active" msgstr "{activeUsers} aktive" -msgid "{count, plural, one {# account has billing drift detected.} other {# accounts have billing drift detected.}}" -msgstr "{count, plural, one {# konto har faktureringsafvigelser.} other {# konti har faktureringsafvigelser.}}" +msgid "{count} accounts have billing drift detected." +msgstr "{count} konti har faktureringsafvigelser." -msgid "{count, plural, one {# account has not been synced yet — MRR trend is incomplete.} other {# accounts have not been synced yet — MRR trend is incomplete.}}" -msgstr "{count, plural, one {# konto er endnu ikke synkroniseret — MRR-tendensen er ufuldstændig.} other {# konti er endnu ikke synkroniseret — MRR-tendensen er ufuldstændig.}}" +msgid "{count} accounts have not been synced yet — MRR trend is incomplete." +msgstr "{count} konti er ikke synkroniseret endnu — MRR-tendensen er ufuldstændig." msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" -#. placeholder {0}: tenant.tenantName +#. placeholder {0}: tenant.name #. placeholder {0}: user.email msgid "{featureFlagDescription} disabled for {0}" msgstr "{featureFlagDescription} deaktiveret for {0}" -#. placeholder {0}: tenant.tenantName +#. placeholder {0}: tenant.name #. placeholder {0}: user.email msgid "{featureFlagDescription} enabled for {0}" msgstr "{featureFlagDescription} aktiveret for {0}" +msgid "{flagName} disabled" +msgstr "{flagName} deaktiveret" + +msgid "{flagName} enabled" +msgstr "{flagName} aktiveret" + msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inaktive" @@ -103,13 +109,16 @@ msgid "90d" msgstr "90d" msgid "A/B rollout" -msgstr "" +msgstr "A/B-udrulning" msgid "Account" msgstr "Konto" msgid "Account actions" -msgstr "" +msgstr "Kontohandlinger" + +msgid "Account flags" +msgstr "Kontoflag" msgid "Account growth" msgstr "Kontovækst" @@ -125,6 +134,9 @@ msgstr "Konto-ID" msgid "Account preview" msgstr "Kontoforhåndsvisning" +msgid "Account status" +msgstr "Kontostatus" + msgid "Account users" msgstr "Kontobrugere" @@ -135,17 +147,14 @@ msgid "Accounts" msgstr "Konti" msgid "Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are available for plan-managed flags." -msgstr "" +msgstr "Konti aktiveres eller deaktiveres automatisk baseret på deres abonnement. Manuelle tilsidesættelser er ikke tilgængelige for abonnementsstyrede flag." msgid "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." -msgstr "" +msgstr "Konti inkluderes automatisk baseret på deres udrulningsbucket. Brug tilsidesættelser til manuelt at inkludere eller udelukke specifikke konti." msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." -msgid "Actions" -msgstr "Handlinger" - msgid "Active" msgstr "Aktiv" @@ -162,7 +171,7 @@ msgid "All" msgstr "Alle" msgid "All accounts this user is a member of, with their plan and role." -msgstr "" +msgstr "Alle konti denne bruger er medlem af, med deres abonnement og rolle." msgid "All event types" msgstr "Alle hændelsestyper" @@ -171,7 +180,7 @@ msgid "All statuses" msgstr "Alle statusser" msgid "All users across every account, most recently seen first. Search and filter to narrow down." -msgstr "" +msgstr "Alle brugere på tværs af alle konti, senest sete først. Søg og filtrér for at indsnævre." msgid "All-time" msgstr "Samlet" @@ -191,10 +200,10 @@ msgstr "Der opstod en uventet fejl ved behandlingen." #. placeholder {0}: result.billingEventsAppended #. placeholder {1}: formatDate(result.reconciledAt) msgid "Appended {0} new billing events. Last reconciled at {1}." -msgstr "" +msgstr "Tilføjede {0} nye faktureringshændelser. Sidst afstemt {1}." msgid "Authoritative log of subscription, payment, and billing transitions across all accounts." -msgstr "" +msgstr "Autoritativ log over abonnements-, betalings- og faktureringsovergange på tværs af alle konti." msgid "Back Office" msgstr "Back Office" @@ -206,13 +215,13 @@ msgid "Back Office overview · {today}" msgstr "Back Office oversigt · {today}" msgid "Back to feature flags" -msgstr "" +msgstr "Tilbage til feature flags" msgid "Basis" msgstr "Basis" msgid "Beta features" -msgstr "Beta-funktioner" +msgstr "Betafunktioner" msgid "Billing" msgstr "Fakturering" @@ -221,13 +230,13 @@ msgid "Billing address" msgstr "Faktureringsadresse" msgid "Billing events" -msgstr "" +msgstr "Faktureringshændelser" msgid "Billing info added" -msgstr "" +msgstr "Faktureringsoplysninger tilføjet" msgid "Billing info updated" -msgstr "" +msgstr "Faktureringsoplysninger opdateret" msgid "blended" msgstr "blandet" @@ -242,7 +251,7 @@ msgid "Bucket" msgstr "Bucket" msgid "Cancel" -msgstr "" +msgstr "Annullér" msgid "Canceled" msgstr "Opsagt" @@ -254,10 +263,10 @@ msgid "Cancellation" msgstr "Opsigelse" msgid "Cancelled" -msgstr "" +msgstr "Annulleret" msgid "Cancelled immediately" -msgstr "" +msgstr "Annulleret med det samme" msgid "Change language" msgstr "Skift sprog" @@ -278,14 +287,11 @@ msgid "Clear search" msgstr "Ryd søgning" msgid "Close" -msgstr "" +msgstr "Luk" msgid "Close account preview" msgstr "Luk kontoforhåndsvisning" -msgid "Coming soon" -msgstr "Kommer snart" - msgid "Compact view" msgstr "Kompakt visning" @@ -327,10 +333,10 @@ msgstr "Mørk" msgid "Dashboard" msgstr "Dashboard" -#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, currency) -#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, currency) +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, data.currency) +#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, data.currency) msgid "Dashboard MRR mismatch: KPI shows {0}, trend latest shows {1}." -msgstr "" +msgstr "Dashboard MRR uoverensstemmelse: KPI viser {0}, tendens viser senest {1}." msgid "Date" msgstr "Dato" @@ -359,10 +365,10 @@ msgid "Disaster recovery from archived Stripe events?" msgstr "Katastrofegendannelse fra arkiverede Stripe-events?" msgid "Downgrade cancelled" -msgstr "" +msgstr "Nedgradering annulleret" msgid "Downgrade scheduled" -msgstr "" +msgstr "Nedgradering planlagt" msgid "Downgraded" msgstr "Nedgraderet" @@ -374,7 +380,7 @@ msgid "Drift detected" msgstr "Afvigelser fundet" msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." -msgstr "Hver konto eller bruger tildeles en fast bucket (0-99) baseret på deres sekvensnummer. Udrulningen rammer et bestemt bucket-interval, så funktionsudrulningen bliver konsistent og forudsigelig." +msgstr "Hver konto eller bruger tildeles en fast bucket (0-99) baseret på deres sekvensnummer. Udrulningen rammer et specifikt interval af buckets, hvilket sikrer en konsistent og forudsigelig udrulning." msgid "Early access to experimental features before general availability" msgstr "Tidlig adgang til eksperimentelle funktioner før generel tilgængelighed" @@ -406,46 +412,43 @@ msgid "Enabled: {0}" msgstr "Aktiveret: {0}" msgid "Enter kiosk mode" -msgstr "" +msgstr "Skift til kiosktilstand" msgid "Event" -msgstr "" +msgstr "Hændelse" msgid "Event view" msgstr "Hændelsesvisning" msgid "Every invoice, refund, and credit note — the money in and out for this subscription." -msgstr "" +msgstr "Alle fakturaer, refunderinger og kreditnotaer — pengestrømmen ind og ud for dette abonnement." msgid "Every invoice, refund, and credit note across all accounts." -msgstr "" +msgstr "Alle fakturaer, refunderinger og kreditnotaer på tværs af alle konti." msgid "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." -msgstr "" +msgstr "Alle login-forsøg de seneste 30 dage, succesfulde eller mislykkede, på tværs af e-mail og eksterne udbydere." msgid "Exit kiosk mode" -msgstr "" +msgstr "Forlad kiosktilstand" msgid "Experimental UI" -msgstr "" +msgstr "Eksperimentel UI" msgid "Expired" -msgstr "" +msgstr "Udløbet" msgid "Expires" -msgstr "" +msgstr "Udløber" msgid "Failed" msgstr "Mislykket" msgid "Feature flag activated" -msgstr "" +msgstr "Feature flag aktiveret" msgid "Feature flag deactivated" -msgstr "" - -msgid "Feature flag detail" -msgstr "" +msgstr "Feature flag deaktiveret" msgid "Feature flags" msgstr "Feature flags" @@ -453,24 +456,24 @@ msgstr "Feature flags" msgid "Feature flags are defined in code. This view controls their activation and rollout." msgstr "Feature flags er defineret i koden. Dette view styrer deres aktivering og udrulning." -msgid "Filter by flag type" -msgstr "Filtrér efter flag-type" - -msgid "Flag name" -msgstr "Flagnavn" - msgid "Free" msgstr "Gratis" msgid "Gated by subscription plan and recomputed when the plan changes. Configured only in code." msgstr "Begrænset af abonnementet og genberegnet når abonnementet ændres. Konfigureres kun i koden." +msgid "Gated by the account's subscription plan. Read-only." +msgstr "Begrænset af kontoens abonnement. Skrivebeskyttet." + msgid "Go to home" msgstr "Gå til forsiden" msgid "Google" msgstr "Google" +msgid "Google OAuth" +msgstr "Google OAuth" + msgid "Hide details" msgstr "Skjul detaljer" @@ -484,10 +487,10 @@ msgid "Invoice view" msgstr "Fakturavisning" msgid "Invoices" -msgstr "" +msgstr "Fakturaer" msgid "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." -msgstr "" +msgstr "Fakturaer vises her efterhånden som konti abonnerer, og Stripe-webhooks behandles." msgid "IP address" msgstr "IP-adresse" @@ -547,7 +550,7 @@ msgid "Login history" msgstr "Login-historik" msgid "Logins" -msgstr "" +msgstr "Logins" msgid "Logo" msgstr "Logo" @@ -556,7 +559,7 @@ msgid "Main navigation" msgstr "Hovednavigation" msgid "Manual override" -msgstr "" +msgstr "Manuel tilsidesættelse" msgid "Member" msgstr "Medlem" @@ -574,10 +577,10 @@ msgid "MRR" msgstr "MRR" msgid "MRR after" -msgstr "" +msgstr "MRR efter" msgid "MRR impact" -msgstr "" +msgstr "MRR-effekt" msgid "MRR trend" msgstr "MRR-tendens" @@ -586,7 +589,7 @@ msgid "Name" msgstr "Navn" msgid "Name:" -msgstr "" +msgstr "Navn:" msgid "Navigation" msgstr "Navigation" @@ -604,7 +607,7 @@ msgid "No account memberships" msgstr "Ingen kontomedlemskaber" msgid "No accounts in this group." -msgstr "" +msgstr "Ingen konti i denne gruppe." msgid "No accounts match your filters" msgstr "Ingen konti matcher dine filtre" @@ -619,25 +622,28 @@ msgid "No billing address on file." msgstr "Ingen faktureringsadresse registreret." msgid "No billing events" -msgstr "" +msgstr "Ingen faktureringshændelser" msgid "No billing events match your filters" -msgstr "" +msgstr "Ingen faktureringshændelser matcher dine filtre" msgid "No billing events yet" -msgstr "" +msgstr "Ingen faktureringshændelser endnu" msgid "No change" msgstr "Ingen ændring" +msgid "No feature flags" +msgstr "Ingen feature flags" + msgid "No invoices match your filters" msgstr "Ingen fakturaer matcher dine filtre" msgid "No invoices yet" -msgstr "" +msgstr "Ingen fakturaer endnu" msgid "No invoices, refunds, or credit notes yet." -msgstr "" +msgstr "Ingen fakturaer, refunderinger eller kreditnotaer endnu." msgid "No login history" msgstr "Ingen login-historik" @@ -646,7 +652,7 @@ msgid "No matching users" msgstr "Ingen matchende brugere" msgid "No new billing events were appended. Account state matches Stripe." -msgstr "" +msgstr "Ingen nye faktureringshændelser blev tilføjet. Kontotilstanden matcher Stripe." msgid "No owners" msgstr "Ingen ejere" @@ -661,13 +667,13 @@ msgid "No plan" msgstr "Intet abonnement" msgid "No recent billing events" -msgstr "" +msgstr "Ingen nylige faktureringshændelser" msgid "No recent logins" -msgstr "" +msgstr "Ingen nylige logins" msgid "No recent payments" -msgstr "" +msgstr "Ingen nylige betalinger" msgid "No recent signups" msgstr "Ingen nylige tilmeldinger" @@ -679,7 +685,7 @@ msgid "No refunds or credit notes yet" msgstr "Ingen refunderinger eller kreditnotaer endnu" msgid "No result available." -msgstr "" +msgstr "Intet resultat tilgængeligt." msgid "No results match your search" msgstr "Ingen resultater matcher din søgning" @@ -690,14 +696,20 @@ msgstr "Ingen sessioner" msgid "No sign-in attempts in the last 30 days." msgstr "Ingen login-forsøg de seneste 30 dage." +msgid "No tenant-scoped feature flags are defined for this account." +msgstr "Der er ingen feature flags defineret for denne konto." + msgid "No transactions" -msgstr "" +msgstr "Ingen transaktioner" + +msgid "No user-scoped feature flags are defined." +msgstr "Der er ingen brugeromfattede feature flags defineret." msgid "No users" msgstr "Ingen brugere" msgid "No users found" -msgstr "" +msgstr "Ingen brugere fundet" msgid "No users match your filters." msgstr "Ingen brugere matcher dine filtre." @@ -706,16 +718,16 @@ msgid "No users match your search" msgstr "Ingen brugere matcher din søgning" msgid "No users yet" -msgstr "" +msgstr "Ingen brugere endnu" msgid "Not synced yet" msgstr "Ikke synkroniseret endnu" msgid "Occurred" -msgstr "" +msgstr "Forekom" msgid "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." -msgstr "" +msgstr "En række pr. enhed eller browser brugeren er logget ind fra. Tilbagekaldte sessioner kan ikke logge ind igen." msgid "One-time password" msgstr "Engangskode" @@ -723,15 +735,22 @@ msgstr "Engangskode" msgid "Open account" msgstr "Åbn konto" +#. placeholder {0}: tenant.name +msgid "Open account {0}" +msgstr "Åbn konto {0}" + msgid "Open credit note" msgstr "Åbn kreditnota" msgid "Open in Stripe" -msgstr "" +msgstr "Åbn i Stripe" msgid "Open invoice" msgstr "Åbn faktura" +msgid "Open user {displayName}" +msgstr "Åbn bruger {displayName}" + msgid "Other" msgstr "Andet" @@ -744,14 +763,22 @@ msgstr "over perioden" msgid "Override" msgstr "Tilsidesættelse" -#. placeholder {0}: tenant.tenantName +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email msgid "Override for {0}" msgstr "Tilsidesættelse for {0}" -#. placeholder {0}: tenant.tenantName +msgid "Override for {flagName}" +msgstr "Tilsidesættelse for {flagName}" + +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email msgid "Override removed for {0}" msgstr "Tilsidesættelse fjernet for {0}" +msgid "Override removed for {flagName}" +msgstr "Tilsidesættelse fjernet for {flagName}" + msgid "Overview" msgstr "Overblik" @@ -765,7 +792,7 @@ msgid "Page not found" msgstr "Siden blev ikke fundet" msgid "Paid" -msgstr "" +msgstr "Betalt" msgid "Past due" msgstr "Forfalden" @@ -774,16 +801,16 @@ msgid "Payment failed" msgstr "Betaling mislykkedes" msgid "Payment method" -msgstr "" +msgstr "Betalingsmetode" msgid "Payment method updated" -msgstr "" +msgstr "Betalingsmetode opdateret" msgid "Payment recovered" -msgstr "" +msgstr "Betaling gendannet" msgid "Payment refunded" -msgstr "" +msgstr "Betaling refunderet" msgid "Pending" msgstr "Afventer" @@ -791,11 +818,17 @@ msgstr "Afventer" msgid "per month, billed monthly" msgstr "pr. måned, faktureres månedligt" +msgid "Per-account flags. Toggle the override switch to enable or disable for this account." +msgstr "Kontoflag. Slå tilsidesættelsesknappen til eller fra for at aktivere eller deaktivere for denne konto." + msgid "Per-tenant flags. Owners can toggle configurable flags. Admins control A/B rollouts." msgstr "Tenant-flag. Ejere kan slå konfigurerbare flag til og fra. Admins styrer A/B-udrulninger." +msgid "Per-user flags. Toggle the override switch to enable or disable for this user." +msgstr "Brugerflag. Slå tilsidesættelsesknappen til eller fra for at aktivere eller deaktivere for denne bruger." + msgid "Per-user flags. Users can toggle configurable flags. Admins control A/B rollouts." -msgstr "Bruger-flag. Brugere kan slå konfigurerbare flag til og fra. Admins styrer A/B-udrulninger." +msgstr "Brugerflag. Brugere kan slå konfigurerbare flag til og fra. Admins styrer A/B-udrulninger." msgid "Period" msgstr "Periode" @@ -807,19 +840,19 @@ msgid "Plan & revenue" msgstr "Abonnement og omsætning" msgid "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." -msgstr "" +msgstr "Abonnementsændringer, fornyelser, opsigelser og betalingsresultater — abonnementets livscyklus og dets MRR-effekt over tid." msgid "Plan distribution" msgstr "Plan-fordeling" msgid "Plan flags" -msgstr "" +msgstr "Abonnementsflag" msgid "Plan transition" -msgstr "" +msgstr "Abonnementsovergang" msgid "Platform" -msgstr "" +msgstr "Platform" msgid "Platform-wide capabilities set at deployment via environment variables. Configured only in code." msgstr "Platformomspændende funktioner sat ved udrulning via miljøvariabler. Konfigureres kun i koden." @@ -843,16 +876,16 @@ msgid "Prior period" msgstr "Forrige periode" msgid "Reactivated" -msgstr "" +msgstr "Genaktiveret" msgid "Recent billing events" -msgstr "" +msgstr "Nylige faktureringshændelser" msgid "Recent logins" -msgstr "" +msgstr "Nylige logins" msgid "Recent payments" -msgstr "" +msgstr "Nylige betalinger" msgid "Recent signups" msgstr "Nylige tilmeldinger" @@ -861,10 +894,10 @@ msgid "Reconcile" msgstr "Afstem" msgid "Reconcile complete" -msgstr "Afstemning fuldført" +msgstr "Afstemning gennemført" msgid "Reconcile complete with drift detected" -msgstr "Afstemning fuldført med afvigelser" +msgstr "Afstemning gennemført med afvigelser fundet" #. placeholder {0}: archivedAwaiting.count #. placeholder {1}: formatDate(archivedAwaiting.oldestOccurredAt) @@ -885,7 +918,7 @@ msgid "Reconciling..." msgstr "Afstemmer..." msgid "Reduce spacing between UI elements for a denser layout" -msgstr "Reducer afstanden mellem UI-elementer for et tættere layout" +msgstr "Reducér afstanden mellem UI-elementer for et tættere layout" msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Posteringer vises her, når konti abonnerer, og Stripe-webhooks behandles." @@ -902,10 +935,14 @@ msgstr "Refunderinger og kreditnotaer på tværs af alle konti." msgid "Remove override" msgstr "Fjern tilsidesættelse" -#. placeholder {0}: tenant.tenantName +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email msgid "Remove override for {0}" msgstr "Fjern tilsidesættelse for {0}" +msgid "Remove override for {flagName}" +msgstr "Fjern tilsidesættelse for {flagName}" + msgid "Renewal" msgstr "Fornyelse" @@ -913,7 +950,7 @@ msgid "Renewal date" msgstr "Fornyelsesdato" msgid "Renewed" -msgstr "" +msgstr "Fornyet" #. placeholder {0}: formatDate(membership.renewalDate) #. placeholder {0}: formatDate(tenant.renewalDate) @@ -947,19 +984,7 @@ msgid "Rollout %" msgstr "Udrulning %" msgid "Rollout bucket information" -msgstr "Information om udrulningsspand" - -msgid "Rollout buckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" -msgstr "Udrulningsspande: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" - -msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" -msgstr "Udrulningsspande: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({rolloutPercentage}%)" - -msgid "Rollout %" -msgstr "Udrulnings-%" - -msgid "Rollout bucket information" -msgstr "Information om udrulnings-bucket" +msgstr "Information om udrulningsbucket" msgid "Rollout buckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" msgstr "Udrulningsbuckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" @@ -967,9 +992,6 @@ msgstr "Udrulningsbuckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPerc msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" msgstr "Udrulningsbuckets: {rolloutBucketStart}-99 og 0-{rolloutBucketEnd} ({rolloutPercentage}%)" -msgid "Rollout percentage" -msgstr "Udrulningsprocent" - msgid "Rollout percentage updated" msgstr "Udrulningsprocent opdateret" @@ -977,10 +999,10 @@ msgid "Run disaster recovery" msgstr "Kør katastrofegendannelse" msgid "Save" -msgstr "" +msgstr "Gem" msgid "Saving..." -msgstr "" +msgstr "Gemmer..." msgid "Scheduled plan change" msgstr "Planlagt planændring" @@ -992,7 +1014,7 @@ msgid "Search" msgstr "Søg" msgid "Search by account name" -msgstr "" +msgstr "Søg efter kontonavn" msgid "Search by account name or ID" msgstr "Søg efter kontonavn eller ID" @@ -1009,14 +1031,11 @@ msgstr "Søg efter navn" msgid "Search by name or email" msgstr "Søg efter navn eller e-mail" -msgid "Search by tenant name or ID" -msgstr "Søg efter lejernavn eller ID" - msgid "Search for users" msgstr "Søg efter brugere" msgid "Search for users by email and toggle the override switch to enable this feature." -msgstr "" +msgstr "Søg efter brugere via e-mail og slå tilsidesættelsesknappen til for at aktivere denne funktion." msgid "Search results" msgstr "Søgeresultater" @@ -1033,6 +1052,9 @@ msgstr "Sessioner" msgid "Show details" msgstr "Vis detaljer" +msgid "Sign in with Google using OpenID Connect" +msgstr "Log ind med Google via OpenID Connect" + msgid "Signed up" msgstr "Tilmeldt" @@ -1046,10 +1068,7 @@ msgid "Since {0}" msgstr "Siden {0}" msgid "Single sign-on" -msgstr "Enkeltlogon" - -msgid "Skip replay" -msgstr "Spring afspilning over" +msgstr "Single sign-on" msgid "Small" msgstr "Lille" @@ -1058,7 +1077,7 @@ msgid "Something went wrong" msgstr "Noget gik galt" msgid "Source" -msgstr "" +msgstr "Kilde" msgid "Standard" msgstr "Standard" @@ -1066,32 +1085,38 @@ msgstr "Standard" msgid "Status" msgstr "Status" +msgid "Stripe-powered subscription billing and plan management" +msgstr "Stripe-drevet abonnementsfakturering og abonnementsstyring" + msgid "Subscribed" msgstr "Tilmeldt" msgid "Subscribed since" msgstr "Abonneret siden" +msgid "Subscription canceled" +msgstr "Abonnement opsagt" + msgid "Subscription state" msgstr "Abonnementstilstand" msgid "Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed." -msgstr "" +msgstr "Abonnements-, betalings- og faktureringsovergange vises her efterhånden som Stripe-webhooks behandles." msgid "Subscription, payment, and billing transitions will appear here." -msgstr "" +msgstr "Abonnements-, betalings- og faktureringsovergange vises her." + +msgid "Subscriptions" +msgstr "Abonnementer" msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." -msgid "Subtotal" -msgstr "Subtotal" - msgid "Succeeded" msgstr "Gennemført" msgid "Successful logins will appear here as users sign in." -msgstr "" +msgstr "Succesfulde logins vises her, efterhånden som brugere logger ind." msgid "Successful, pending, and failed invoices across all accounts." msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle konti." @@ -1102,18 +1127,12 @@ msgstr "Suspenderet" msgid "System" msgstr "System" +msgid "System flags" +msgstr "Systemflag" + msgid "Tablet" msgstr "Tablet" -msgid "Tenant" -msgstr "" - -msgid "Tenant ID" -msgstr "Lejer-ID" - -msgid "Tenant overrides" -msgstr "" - msgid "The page you are looking for does not exist or was moved." msgstr "Siden du leder efter findes ikke eller er blevet flyttet." @@ -1130,7 +1149,7 @@ msgid "This account previously had a paid subscription that ended." msgstr "Denne konto havde tidligere et betalt abonnement, der sluttede." msgid "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." -msgstr "Dette flag administreres af abonnementet. Det aktiveres automatisk for konti på det krævede abonnement eller højere." +msgstr "Dette flag styres af abonnementet. Det aktiveres automatisk for konti på det krævede abonnement eller højere." msgid "This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort recovery that may produce incorrect subscription state or billing event rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." msgstr "Dette genopbygger faktureringshændelsesloggen fra denne kontos arkiverede Stripe-payloads. Det er en bedst muligt-gendannelse, der kan producere forkerte abonnementstilstande eller faktureringshændelser. Kør kun denne handling, når standard Afstem med Stripe er forsøgt og ikke ryddede afvigelserne." @@ -1143,13 +1162,13 @@ msgstr "Denne bruger er ikke medlem af nogen konto." #. placeholder {0}: getFeatureFlagName(featureFlag.key) msgid "Toggle {0}" -msgstr "Skift {0}" +msgstr "Slå {0} til/fra" msgid "Toggle the override switch to enable this feature for specific accounts." -msgstr "" +msgstr "Slå tilsidesættelsesknappen til for at aktivere denne funktion for specifikke konti." msgid "Total" -msgstr "" +msgstr "I alt" msgid "Total accounts" msgstr "Konti i alt" @@ -1161,7 +1180,7 @@ msgid "Try a different search term or clear the role and activity filters." msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." msgid "Try adjusting your search" -msgstr "" +msgstr "Prøv at justere din søgning" msgid "Try again" msgstr "Prøv igen" @@ -1179,10 +1198,7 @@ msgid "Type" msgstr "Type" msgid "Type an email address above to find users and manage their overrides" -msgstr "Indtast en e-mailadresse ovenfor for at finde brugere og administrere deres tilsidesættelser" - -msgid "Type an email address above to find users and manage their overrides" -msgstr "" +msgstr "Indtast en e-mailadresse ovenfor for at finde brugere og styre deres tilsidesættelser" msgid "Unclassified" msgstr "Uklassificeret" @@ -1199,12 +1215,18 @@ msgstr "Bruger" msgid "User detail" msgstr "Brugerdetalje" +msgid "User flags" +msgstr "Brugerflag" + msgid "User logins / day" msgstr "Brugerlogins / dag" msgid "User menu" msgstr "Brugermenu" +msgid "User status" +msgstr "Brugerstatus" + msgid "Users" msgstr "Brugere" @@ -1212,31 +1234,31 @@ msgid "Users active" msgstr "Aktive brugere" msgid "Users are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific users." -msgstr "" +msgstr "Brugere inkluderes automatisk baseret på deres udrulningsbucket. Brug tilsidesættelser til manuelt at inkludere eller udelukke specifikke brugere." msgid "Users will appear here as accounts are created." -msgstr "" +msgstr "Brugere vises her efterhånden som konti oprettes." msgid "VAT" -msgstr "" +msgstr "Moms" msgid "VAT number" msgstr "Momsnummer" msgid "View accounts" -msgstr "" +msgstr "Vis konti" msgid "View all" msgstr "Vis alle" msgid "View all {totalEvents} events" -msgstr "" +msgstr "Vis alle {totalEvents} hændelser" msgid "View all {totalTransactions} invoices" -msgstr "" +msgstr "Vis alle {totalTransactions} fakturaer" msgid "View billing events" -msgstr "" +msgstr "Vis faktureringshændelser" msgid "vs prior period" msgstr "mod forrige periode" diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs index 8e845e3a6..c54ecf691 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs @@ -15,9 +15,9 @@ public sealed record RemoveUserFeatureFlagOverrideCommand : ICommand, IRequest @@ -36,19 +36,19 @@ public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository { public async Task Handle(RemoveUserFeatureFlagOverrideCommand command, CancellationToken cancellationToken) { - var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, command.UserId.Value, cancellationToken); if (userOverride is null) return Result.NotFound($"No user override found for flag '{command.FlagKey}' and user '{command.UserId}'."); featureFlagRepository.Remove(userOverride); - var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); if (tenant is not null) { tenant.IncrementFeatureFlagVersion(); tenantRepository.Update(tenant); } - events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId)); + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId.Value)); return Result.Success(); } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs index f73f0dd48..5d4c727ce 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -15,9 +15,9 @@ public sealed record SetUserFeatureFlagInternalCommand : ICommand, IRequest Handle(SetUserFeatureFlagInternalCommand command, Canc if (command.Enabled) { - var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, command.UserId.Value, cancellationToken); if (userOverride is null) { - userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId, command.UserId); + userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId.Value, command.UserId.Value); userOverride.Activate(now); await featureFlagRepository.AddAsync(userOverride, cancellationToken); } @@ -55,14 +55,14 @@ public async Task Handle(SetUserFeatureFlagInternalCommand command, Canc featureFlagRepository.Update(userOverride); } - events.CollectEvent(new FeatureFlagUserOverrideSet(command.FlagKey, command.UserId)); + events.CollectEvent(new FeatureFlagUserOverrideSet(command.FlagKey, command.UserId.Value)); } else { - var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, command.UserId.Value, cancellationToken); if (userOverride is null) { - userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId, command.UserId); + userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId.Value, command.UserId.Value); await featureFlagRepository.AddAsync(userOverride, cancellationToken); } else @@ -71,10 +71,10 @@ public async Task Handle(SetUserFeatureFlagInternalCommand command, Canc featureFlagRepository.Update(userOverride); } - events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId)); + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId.Value)); } - var tenant = await tenantRepository.GetByIdUnfilteredAsync(new TenantId(command.TenantId), cancellationToken); + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); if (tenant is not null) { tenant.IncrementFeatureFlagVersion(); diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index 873ebf801..543bb4abb 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -9,6 +9,10 @@ public interface IFeatureFlagRepository : ICrudRepository GetAllRelevantRowsAsync(long tenantId, string userId, CancellationToken cancellationToken); + Task GetTenantScopedRowsAsync(long tenantId, CancellationToken cancellationToken); + + Task GetUserScopedRowsAsync(long tenantId, string userId, CancellationToken cancellationToken); + Task GetAllBaseRowsAsync(CancellationToken cancellationToken); Task GetTenantOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); @@ -30,6 +34,20 @@ public async Task GetAllRelevantRowsAsync(long tenantId, string u .ToArrayAsync(cancellationToken); } + public async Task GetTenantScopedRowsAsync(long tenantId, CancellationToken cancellationToken) + { + return await DbSet + .Where(f => (f.TenantId == null || f.TenantId == tenantId) && f.UserId == null) + .ToArrayAsync(cancellationToken); + } + + public async Task GetUserScopedRowsAsync(long tenantId, string userId, CancellationToken cancellationToken) + { + return await DbSet + .Where(f => (f.TenantId == null && f.UserId == null) || (f.TenantId == tenantId && f.UserId == userId)) + .ToArrayAsync(cancellationToken); + } + public async Task GetAllBaseRowsAsync(CancellationToken cancellationToken) { return await DbSet diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs index b38aab8c4..434c0c1e6 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs @@ -1,7 +1,11 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; +using Mapster; using SharedKernel.Cqrs; using SharedKernel.Domain; using SharedKernel.FeatureFlags; @@ -18,11 +22,23 @@ public sealed record GetFeatureFlagTenantsQuery : IRequest> +public sealed class GetFeatureFlagTenantsHandler( + IFeatureFlagRepository featureFlagRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository, + IUserRepository userRepository +) : IRequestHandler> { public async Task> Handle(GetFeatureFlagTenantsQuery query, CancellationToken cancellationToken) { @@ -48,6 +68,17 @@ public async Task> Handle(GetFeatureFlagTe if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var tenantIds = tenants.Select(t => t.Id).ToArray(); + + var subscriptions = tenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + var ownerByTenantId = tenantIds.Length == 0 + ? new Dictionary() + : await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantOverrides = await featureFlagRepository.GetTenantOverridesForFlagAsync(query.FlagKey, cancellationToken); var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!.Value); @@ -55,22 +86,45 @@ public async Task> Handle(GetFeatureFlagTe var featureFlagTenants = tenants.Select(tenant => { - if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) - { - var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); - return new FeatureFlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isEnabled, "manual_override"); - } + var summary = TenantSummary.FromAggregate( + tenant, + subscriptionsByTenantId.GetValueOrDefault(tenant.Id), + ownerByTenantId.GetValueOrDefault(tenant.Id) + ); - if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) - { - var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FeatureFlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, isInRange, "ab_rollout"); - } + var (isEnabled, source) = EvaluateOverride(definition, baseRow, overridesByTenantId, tenant); - return new FeatureFlagTenantInfo(tenant.Id, tenant.Name, tenant.Plan.ToString(), tenant.RolloutBucket, false, "default"); + return summary.Adapt() with + { + RolloutBucket = tenant.RolloutBucket, IsEnabled = isEnabled, Source = source + }; } ).ToArray(); return new GetFeatureFlagTenantsResponse(featureFlagTenants); } + + private static (bool IsEnabled, string Source) EvaluateOverride( + FeatureFlagDefinition definition, + FeatureFlag? baseRow, + Dictionary overridesByTenantId, + Tenant tenant + ) + { + if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) + { + var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); + // The override row's Source column distinguishes a manual admin toggle from a plan-driven row. + var source = tenantOverride.Source == FeatureFlagSource.Plan ? "plan" : "manual_override"; + return (isEnabled, source); + } + + if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return (isInRange, "ab_rollout"); + } + + return (false, "default"); + } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs index 0bf41f19e..30d053cc9 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs @@ -1,8 +1,10 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; +using Mapster; using SharedKernel.Cqrs; using SharedKernel.Domain; using SharedKernel.FeatureFlags; @@ -21,12 +23,21 @@ public sealed record GetFeatureFlagUsersQuery : IRequest> Handle(GetFeatureFlagUser var featureFlagUsers = users.Select(user => { - var tenantName = tenantsById.TryGetValue(user.TenantId, out var tenant) ? tenant.Name : "Unknown"; + tenantsById.TryGetValue(user.TenantId, out var tenant); + var (isEnabled, source) = EvaluateOverride(definition, baseRow, overridesByUserId, user); - if (overridesByUserId.TryGetValue(user.Id.Value, out var userOverride)) + return user.Adapt() with { - var isEnabled = userOverride.EnabledAt is not null && (userOverride.DisabledAt is null || userOverride.EnabledAt > userOverride.DisabledAt); - return new FeatureFlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isEnabled, "manual_override"); - } - - if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) - { - var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(user.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return new FeatureFlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, isInRange, "ab_rollout"); - } - - return new FeatureFlagUserInfo(user.Id, user.TenantId, user.Email, tenantName, user.RolloutBucket, false, "default"); + AvatarUrl = user.Avatar.Url, + TenantName = tenant?.Name ?? "Unknown", + TenantPlan = tenant?.Plan ?? SubscriptionPlan.Basis, + IsEnabled = isEnabled, + Source = source + }; } ).ToArray(); return new GetFeatureFlagUsersResponse(featureFlagUsers); } + + private static (bool IsEnabled, string Source) EvaluateOverride( + FeatureFlagDefinition definition, + FeatureFlag? baseRow, + Dictionary overridesByUserId, + User user + ) + { + if (overridesByUserId.TryGetValue(user.Id.Value, out var userOverride)) + { + var isEnabled = userOverride.EnabledAt is not null && (userOverride.DisabledAt is null || userOverride.EnabledAt > userOverride.DisabledAt); + return (isEnabled, "manual_override"); + } + + if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(user.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + return (isInRange, "ab_rollout"); + } + + return (false, "default"); + } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs new file mode 100644 index 000000000..a45e120b7 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs @@ -0,0 +1,125 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetTenantFeatureFlagsQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId TenantId { get; init; } = null!; +} + +[PublicAPI] +public sealed record GetTenantFeatureFlagsResponse(TenantFeatureFlagInfo[] Flags); + +[PublicAPI] +public sealed record TenantFeatureFlagInfo( + string FlagKey, + FeatureFlagScope Scope, + string Description, + string? RequiredPlan, + bool IsAbTestEligible, + int? BucketStart, + int? BucketEnd, + int? RolloutPercentage, + bool IsEnabled, + string Source, + bool IsBaseRowActive, + int RolloutBucket +); + +public sealed class GetTenantFeatureFlagsHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantFeatureFlagsQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.TenantId, cancellationToken); + if (tenant is null) return Result.NotFound($"Tenant with ID '{query.TenantId}' not found."); + + var tenantScopedDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll() + .Where(f => f.Scope == FeatureFlagScope.Tenant) + .ToArray(); + + var allRows = await featureFlagRepository.GetTenantScopedRowsAsync(tenant.Id.Value, cancellationToken); + var baseRowsByKey = allRows.Where(r => r.TenantId is null).ToDictionary(r => r.FlagKey); + var tenantOverridesByKey = allRows.Where(r => r.TenantId == tenant.Id.Value).ToDictionary(r => r.FlagKey); + + var flags = tenantScopedDefinitions + .Select(definition => Evaluate(definition, baseRowsByKey, tenantOverridesByKey, tenant.RolloutBucket)) + .ToArray(); + + return new GetTenantFeatureFlagsResponse(flags); + } + + private static TenantFeatureFlagInfo Evaluate( + FeatureFlagDefinition definition, + Dictionary baseRowsByKey, + Dictionary tenantOverridesByKey, + int tenantRolloutBucket + ) + { + baseRowsByKey.TryGetValue(definition.Key, out var baseRow); + var isBaseRowActive = baseRow is not null && IsActive(baseRow); + tenantOverridesByKey.TryGetValue(definition.Key, out var tenantOverride); + + bool isEnabled; + string source; + + if (tenantOverride is not null) + { + isEnabled = IsActive(tenantOverride); + // The row's Source column is authoritative — a manually-toggled plan-gated flag must still surface as + // "manual_override" so admins see they overrode the plan-driven default, rather than the plan granting it. + source = tenantOverride.Source == FeatureFlagSource.Plan ? "plan" : "manual_override"; + } + else if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + isEnabled = isBaseRowActive + && RolloutBucketHasher.IsInRolloutBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + source = "ab_rollout"; + } + else + { + isEnabled = false; + source = "default"; + } + + return new TenantFeatureFlagInfo( + definition.Key, + definition.Scope, + definition.Description, + definition.RequiredPlan?.ToString(), + definition.IsAbTestEligible, + baseRow?.BucketStart, + baseRow?.BucketEnd, + ComputeRolloutPercentage(baseRow?.BucketStart, baseRow?.BucketEnd), + isEnabled, + source, + isBaseRowActive, + tenantRolloutBucket + ); + } + + private static bool IsActive(FeatureFlag featureFlag) + { + return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); + } + + private static int? ComputeRolloutPercentage(int? bucketStart, int? bucketEnd) + { + if (bucketStart is null || bucketEnd is null) return null; + + // 100% rollout uses reserved range 0-100 + if (bucketStart == 0 && bucketEnd == 100) return 100; + + if (bucketStart <= bucketEnd) return bucketEnd.Value - bucketStart.Value + 1; + + // Wrap-around case within 1-99 range + return 99 - bucketStart.Value + 1 + bucketEnd.Value; + } +} diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs new file mode 100644 index 000000000..6f3251afd --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs @@ -0,0 +1,124 @@ +using Account.Features.FeatureFlags.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Features.FeatureFlags.Queries; + +[PublicAPI] +public sealed record GetUserFeatureFlagsQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public UserId UserId { get; init; } = null!; +} + +[PublicAPI] +public sealed record GetUserFeatureFlagsResponse(UserFeatureFlagInfo[] Flags); + +[PublicAPI] +public sealed record UserFeatureFlagInfo( + string FlagKey, + FeatureFlagScope Scope, + string Description, + bool IsAbTestEligible, + int? BucketStart, + int? BucketEnd, + int? RolloutPercentage, + bool IsEnabled, + string Source, + bool IsBaseRowActive, + int RolloutBucket, + TenantId TenantId +); + +public sealed class GetUserFeatureFlagsHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUserFeatureFlagsQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.UserId, cancellationToken); + if (user is null) return Result.NotFound($"User with ID '{query.UserId}' not found."); + + var userScopedDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll() + .Where(f => f.Scope == FeatureFlagScope.User) + .ToArray(); + + var allRows = await featureFlagRepository.GetUserScopedRowsAsync(user.TenantId.Value, user.Id.Value, cancellationToken); + var baseRowsByKey = allRows.Where(r => r.TenantId is null && r.UserId is null).ToDictionary(r => r.FlagKey); + var userOverridesByKey = allRows.Where(r => r.UserId == user.Id.Value).ToDictionary(r => r.FlagKey); + + var flags = userScopedDefinitions + .Select(definition => Evaluate(definition, baseRowsByKey, userOverridesByKey, user.RolloutBucket, user.TenantId)) + .ToArray(); + + return new GetUserFeatureFlagsResponse(flags); + } + + private static UserFeatureFlagInfo Evaluate( + FeatureFlagDefinition definition, + Dictionary baseRowsByKey, + Dictionary userOverridesByKey, + int userRolloutBucket, + TenantId tenantId + ) + { + baseRowsByKey.TryGetValue(definition.Key, out var baseRow); + var isBaseRowActive = baseRow is not null && IsActive(baseRow); + userOverridesByKey.TryGetValue(definition.Key, out var userOverride); + + bool isEnabled; + string source; + + if (userOverride is not null) + { + isEnabled = IsActive(userOverride); + source = "manual_override"; + } + else if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) + { + isEnabled = isBaseRowActive + && RolloutBucketHasher.IsInRolloutBucketRange(userRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); + source = "ab_rollout"; + } + else + { + isEnabled = false; + source = "default"; + } + + return new UserFeatureFlagInfo( + definition.Key, + definition.Scope, + definition.Description, + definition.IsAbTestEligible, + baseRow?.BucketStart, + baseRow?.BucketEnd, + ComputeRolloutPercentage(baseRow?.BucketStart, baseRow?.BucketEnd), + isEnabled, + source, + isBaseRowActive, + userRolloutBucket, + tenantId + ); + } + + private static bool IsActive(FeatureFlag featureFlag) + { + return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); + } + + private static int? ComputeRolloutPercentage(int? bucketStart, int? bucketEnd) + { + if (bucketStart is null || bucketEnd is null) return null; + + // 100% rollout uses reserved range 0-100 + if (bucketStart == 0 && bucketEnd == 100) return 100; + + if (bucketStart <= bucketEnd) return bucketEnd.Value - bucketStart.Value + 1; + + // Wrap-around case within 1-99 range + return 99 - bucketStart.Value + 1 + bucketEnd.Value; + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs index 818d0643c..a61758033 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs @@ -48,7 +48,42 @@ public sealed record TenantSummary( DateTimeOffset CreatedAt, DateTimeOffset? ModifiedAt, TenantOwnerSummary? Owner -); +) +{ + // Shared factory used by every back-office view that renders the rich tenant row (accounts list + feature-flag + // override views). Keeping construction co-located with the record avoids drift between call sites. + public static TenantSummary FromAggregate(Tenant tenant, Subscription? subscription, User? owner) + { + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + + // Refunded counts as "ever subscribed" — money flowed in before being credited back, so the tenant did pay at + // some point. Distinguishes a refunded customer (Canceled) from never having paid at all (Free). + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status is PaymentTransactionStatus.Succeeded or PaymentTransactionStatus.Refunded) == true; + + return new TenantSummary( + tenant.Id, + tenant.Name, + tenant.Logo.Url, + tenant.Plan, + subscription?.CurrentPriceAmount, + subscription?.ScheduledPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + plannedChange, + hasEverSubscribed, + subscription?.BillingInfo?.Address?.Country, + tenant.CreatedAt, + tenant.ModifiedAt, + owner is null ? null : new TenantOwnerSummary(owner.Id, owner.FirstName, owner.LastName, owner.Email) + ); + } +} [PublicAPI] public sealed record TenantOwnerSummary(UserId UserId, string? FirstName, string? LastName, string Email); @@ -136,7 +171,7 @@ public async Task> Handle(GetTenantsQuery query, Cancell ? new Dictionary() : await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenants.Select(t => t.Id).ToArray(), cancellationToken); - var summaries = tenants.Select(tenant => MapTenantSummary( + var summaries = tenants.Select(tenant => TenantSummary.FromAggregate( tenant, subscriptionsByTenantId.GetValueOrDefault(tenant.Id), ownerByTenantId.GetValueOrDefault(tenant.Id) @@ -206,37 +241,4 @@ private static TenantStatusFilter GetStatus(TenantSummary summary) _ => TenantStatusFilter.Free }; } - - private static TenantSummary MapTenantSummary(Tenant tenant, Subscription? subscription, User? owner) - { - var plannedChange = subscription switch - { - { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, - { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, - _ => (PlannedSubscriptionChange?)null - }; - - // Refunded counts as "ever subscribed" — money flowed in before being credited back, so the - // tenant did pay at some point. Distinguishes a refunded customer (Canceled) from never having - // paid at all (Free). - var hasEverSubscribed = subscription?.PaymentTransactions - .Any(transaction => transaction.Status is PaymentTransactionStatus.Succeeded or PaymentTransactionStatus.Refunded) == true; - - return new TenantSummary( - tenant.Id, - tenant.Name, - tenant.Logo.Url, - tenant.Plan, - subscription?.CurrentPriceAmount, - subscription?.ScheduledPriceAmount, - subscription?.CurrentPriceCurrency, - subscription?.CurrentPeriodEnd, - plannedChange, - hasEverSubscribed, - subscription?.BillingInfo?.Address?.Country, - tenant.CreatedAt, - tenant.ModifiedAt, - owner is null ? null : new TenantOwnerSummary(owner.Id, owner.FirstName, owner.LastName, owner.Email) - ); - } } diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index fa5d38220..006b44ac9 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -233,7 +233,7 @@ public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDelet ("created_at", TimeProvider.GetUtcNow()), ("modified_at", null), ("flag_key", flagKey), - ("tenant_id", tenantId), + ("tenant_id", tenantId.Value), ("user_id", null), ("enabled_at", TimeProvider.GetUtcNow()), ("disabled_at", null), @@ -391,7 +391,7 @@ public async Task SetUserFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow { // Arrange var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var userId = DatabaseSeeder.Tenant1Owner.Id; var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetUserFeatureFlagInternalCommand { UserId = userId, TenantId = tenantId, Enabled = true }; @@ -403,7 +403,7 @@ public async Task SetUserFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow var rowCount = Connection.ExecuteScalar( "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", - [new { flagKey, userId }] + [new { flagKey, userId = userId.Value }] ); rowCount.Should().Be(1); @@ -425,7 +425,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenOverrideExists_ShouldDeleteR ("created_at", TimeProvider.GetUtcNow()), ("modified_at", null), ("flag_key", flagKey), - ("tenant_id", tenantId), + ("tenant_id", tenantId.Value), ("user_id", userId), ("enabled_at", TimeProvider.GetUtcNow()), ("disabled_at", null), @@ -515,7 +515,7 @@ public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithMa ("created_at", TimeProvider.GetUtcNow()), ("modified_at", null), ("flag_key", flagKey), - ("tenant_id", tenantId), + ("tenant_id", tenantId.Value), ("user_id", userId), ("enabled_at", TimeProvider.GetUtcNow()), ("disabled_at", null), @@ -535,7 +535,7 @@ public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithMa var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Users.Should().NotBeEmpty(); - var userResult = result.Users.Single(u => u.UserId.Value == userId); + var userResult = result.Users.Single(u => u.Id.Value == userId); userResult.IsEnabled.Should().BeTrue(); userResult.Source.Should().Be("manual_override"); userResult.Email.Should().NotBe("Unknown"); @@ -765,7 +765,7 @@ public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManual ("created_at", TimeProvider.GetUtcNow()), ("modified_at", null), ("flag_key", flagKey), - ("tenant_id", tenantId), + ("tenant_id", tenantId.Value), ("user_id", null), ("enabled_at", TimeProvider.GetUtcNow()), ("disabled_at", null), @@ -784,7 +784,7 @@ public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManual response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - var tenantResult = result.Tenants.Single(t => t.TenantId.Value == tenantId); + var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); tenantResult.IsEnabled.Should().BeTrue(); tenantResult.Source.Should().Be("manual_override"); } @@ -840,7 +840,7 @@ public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRoll ("created_at", TimeProvider.GetUtcNow()), ("modified_at", null), ("flag_key", flagKey), - ("tenant_id", tenantId), + ("tenant_id", tenantId.Value), ("user_id", null), ("enabled_at", null), ("disabled_at", TimeProvider.GetUtcNow()), @@ -859,7 +859,7 @@ public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRoll response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - var tenantResult = result.Tenants.Single(t => t.TenantId.Value == tenantId); + var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); tenantResult.IsEnabled.Should().BeFalse(); tenantResult.Source.Should().Be("manual_override"); } @@ -997,7 +997,7 @@ public async Task ActivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatur var flagKey = "sso"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); // Act @@ -1007,7 +1007,7 @@ public async Task ActivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatur response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); updatedVersion.Should().Be(originalVersion + 1); } @@ -1019,7 +1019,7 @@ public async Task DeactivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeat var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); // Act @@ -1029,7 +1029,7 @@ public async Task DeactivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeat response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); updatedVersion.Should().Be(originalVersion + 1); } @@ -1041,7 +1041,7 @@ public async Task SetTenantFeatureFlagInternal_WhenCalled_ShouldIncrementTenantF var flagKey = "sso"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; @@ -1052,7 +1052,7 @@ public async Task SetTenantFeatureFlagInternal_WhenCalled_ShouldIncrementTenantF response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); updatedVersion.Should().Be(originalVersion + 1); } @@ -1064,7 +1064,7 @@ public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeat var flagKey = "custom-branding"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; @@ -1075,7 +1075,7 @@ public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeat response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); updatedVersion.Should().Be(originalVersion + 1); } @@ -1087,7 +1087,7 @@ public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlag var flagKey = "compact-view"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); var command = new SetUserFeatureFlagCommand { Enabled = true }; @@ -1098,7 +1098,7 @@ public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlag response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); updatedVersion.Should().Be(originalVersion + 1); } @@ -1110,7 +1110,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllT var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; @@ -1121,7 +1121,7 @@ public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllT response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId }] + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); updatedVersion.Should().Be(originalVersion + 1); } diff --git a/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs b/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs new file mode 100644 index 000000000..853ab3ea4 --- /dev/null +++ b/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Features.FeatureFlags.Domain; +using Account.Features.FeatureFlags.Queries; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.FeatureFlags; + +public sealed class GetTenantFeatureFlagsTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetTenantFeatureFlags_WhenCalled_ShouldReturnAllTenantScopedFlagsWithDefaultSource() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Flags.Should().NotBeEmpty(); + payload.Flags.Should().Contain(f => f.FlagKey == "beta-features"); + payload.Flags.Should().Contain(f => f.FlagKey == "sso"); + payload.Flags.Should().Contain(f => f.FlagKey == "custom-branding"); + } + + [Fact] + public async Task GetTenantFeatureFlags_WhenTenantHasManualOverrideOnPlanGatedFlag_ShouldReturnManualOverrideSource() + { + // Arrange — plan-gated flag (sso) but the row Source column is "Manual" (admin manually toggled it on). + var tenantId = DatabaseSeeder.Tenant1.Id; + var flagKey = "sso"; + InsertTenantOverride(tenantId, flagKey, "Manual", TimeProvider.System.GetUtcNow()); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + var sso = payload.Flags.Single(f => f.FlagKey == "sso"); + sso.IsEnabled.Should().BeTrue(); + sso.Source.Should().Be("manual_override"); + } + + [Fact] + public async Task GetTenantFeatureFlags_WhenTenantHasPlanGrantedRow_ShouldReturnPlanSource() + { + // Arrange — plan-gated flag (sso) with Source="Plan" (the upgrade evaluator wrote it). + var tenantId = DatabaseSeeder.Tenant1.Id; + var flagKey = "sso"; + InsertTenantOverride(tenantId, flagKey, "Plan", TimeProvider.System.GetUtcNow()); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + var sso = payload.Flags.Single(f => f.FlagKey == "sso"); + sso.IsEnabled.Should().BeTrue(); + sso.Source.Should().Be("plan"); + } + + [Fact] + public async Task GetTenantFeatureFlags_WhenTenantInAbRolloutRange_ShouldReturnAbRolloutSourceEnabled() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + var flagKey = "beta-features"; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 99) + ] + ); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + var betaFeatures = payload.Flags.Single(f => f.FlagKey == "beta-features"); + betaFeatures.IsEnabled.Should().BeTrue(); + betaFeatures.Source.Should().Be("ab_rollout"); + } + + [Fact] + public async Task GetTenantFeatureFlags_WhenTenantDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var nonExistentId = TenantId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{nonExistentId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetTenantFeatureFlags_WhenScopeFilteredToTenant_ShouldOnlyIncludeTenantScopedFlags() + { + // Arrange + var tenantId = DatabaseSeeder.Tenant1.Id; + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Flags.Should().NotContain(f => f.FlagKey == "google-oauth"); + payload.Flags.Should().NotContain(f => f.FlagKey == "subscriptions"); + payload.Flags.Should().NotContain(f => f.FlagKey == "compact-view"); + payload.Flags.Should().NotContain(f => f.FlagKey == "experimental-ui"); + } + + private void InsertTenantOverride(TenantId tenantId, string flagKey, string source, DateTimeOffset? enabledAt) + { + Connection.Insert("feature_flags", [ + ("id", FeatureFlagId.NewId().ToString()), + ("created_at", TimeProvider.System.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", null), + ("enabled_at", enabledAt), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", source) + ] + ); + } +} diff --git a/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs b/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs new file mode 100644 index 000000000..ba081e84b --- /dev/null +++ b/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Features.FeatureFlags.Domain; +using Account.Features.FeatureFlags.Queries; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.FeatureFlags; + +public sealed class GetUserFeatureFlagsTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetUserFeatureFlags_WhenCalled_ShouldReturnAllUserScopedFlags() + { + // Arrange + var userId = DatabaseSeeder.Tenant1Owner.Id; + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/users/{userId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Flags.Should().Contain(f => f.FlagKey == "compact-view"); + payload.Flags.Should().Contain(f => f.FlagKey == "experimental-ui"); + } + + [Fact] + public async Task GetUserFeatureFlags_WhenUserHasManualOverride_ShouldReturnManualOverrideSource() + { + // Arrange + var userId = DatabaseSeeder.Tenant1Owner.Id; + var tenantId = DatabaseSeeder.Tenant1.Id; + var flagKey = "compact-view"; + Connection.Insert("feature_flags", [ + ("id", FeatureFlagId.NewId().ToString()), + ("created_at", TimeProvider.System.GetUtcNow()), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", userId.Value), + ("enabled_at", TimeProvider.System.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/users/{userId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + var compactView = payload.Flags.Single(f => f.FlagKey == "compact-view"); + compactView.IsEnabled.Should().BeTrue(); + compactView.Source.Should().Be("manual_override"); + } + + [Fact] + public async Task GetUserFeatureFlags_WhenUserInAbRolloutRange_ShouldReturnAbRolloutSourceEnabled() + { + // Arrange + var userId = DatabaseSeeder.Tenant1Owner.Id; + var flagKey = "experimental-ui"; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 99) + ] + ); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/users/{userId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + var experimentalUi = payload.Flags.Single(f => f.FlagKey == "experimental-ui"); + experimentalUi.IsEnabled.Should().BeTrue(); + experimentalUi.Source.Should().Be("ab_rollout"); + } + + [Fact] + public async Task GetUserFeatureFlags_WhenUserDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var nonExistentId = UserId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/users/{nonExistentId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetUserFeatureFlags_WhenScopeFilteredToUser_ShouldOnlyIncludeUserScopedFlags() + { + // Arrange + var userId = DatabaseSeeder.Tenant1Owner.Id; + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/users/{userId}/feature-flags"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + // No system or tenant scoped flags should leak through + payload.Flags.Should().NotContain(f => f.FlagKey == "google-oauth"); + payload.Flags.Should().NotContain(f => f.FlagKey == "sso"); + payload.Flags.Should().NotContain(f => f.FlagKey == "beta-features"); + payload.Flags.Should().NotContain(f => f.FlagKey == "custom-branding"); + } +} diff --git a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs index 5c6e2ef00..85edc8485 100644 --- a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs +++ b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs @@ -77,7 +77,9 @@ private TenantId SeedTenant(string name, string plan = nameof(SubscriptionPlan.P ("name", name), ("state", nameof(TenantState.Active)), ("plan", plan), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 0), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs index 939374750..692dca0a2 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagScope.cs @@ -1,5 +1,6 @@ namespace SharedKernel.FeatureFlags; +[JsonConverter(typeof(JsonStringEnumConverter))] public enum FeatureFlagScope { System, From 75fede0dc2c1d760d259dc88a998b8d5fd6c9936 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 11 May 2026 19:53:19 +0200 Subject: [PATCH 049/155] Add paginated filter toolbars and filterable backend queries on feature-flag detail page --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 8 +- .../Api/Endpoints/FeatureFlagEndpoints.cs | 8 +- .../routes/feature-flags/$flagKey.tsx | 67 ++-- .../-components/FeatureFlagTenantsToolbar.tsx | 130 +++++++ .../-components/FeatureFlagUsersToolbar.tsx | 130 +++++++ .../-components/PlanFeatureFlagSections.tsx | 57 +++- .../-components/TenantOverridesSection.tsx | 212 ++++++------ .../-components/UserEmptyState.tsx | 33 -- .../-components/UserOverridesSection.tsx | 261 ++++++--------- .../-components/UserOverridesTable.tsx | 68 ++++ .../-components/rolloutBucket.ts | 40 --- .../feature-flags/-components/stateFilter.ts | 31 ++ .../routes/feature-flags/-components/types.ts | 4 - .../shared/translations/locale/da-DK.po | 63 ++-- .../Queries/GetFeatureFlagTenants.cs | 55 ++- .../Queries/GetFeatureFlagUsers.cs | 53 ++- .../Tests/FeatureFlags/FeatureFlagTests.cs | 316 +++++++++++++++++- .../tests/e2e/feature-flag-flows.spec.ts | 213 +++++++++++- .../FeatureFlags/FeatureFlagAudienceState.cs | 9 + 19 files changed, 1328 insertions(+), 430 deletions(-) create mode 100644 application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/FeatureFlagUsersToolbar.tsx delete mode 100644 application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/UserOverridesTable.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts create mode 100644 application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAudienceState.cs diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 5aead7339..c9ba1343a 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -28,8 +28,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new GetFeatureFlagsQuery()) ).Produces(); - group.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) - => await mediator.Send(new GetFeatureFlagTenantsQuery { FlagKey = flagKey }) + group.MapGet("/{flagKey}/tenants", async Task> (string flagKey, [AsParameters] GetFeatureFlagTenantsQuery query, IMediator mediator) + => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) @@ -52,8 +52,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - group.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) - => await mediator.Send(new GetFeatureFlagUsersQuery { FlagKey = flagKey, Search = search }) + group.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFeatureFlagUsersQuery query, IMediator mediator) + => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 68960fb79..302649844 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -18,8 +18,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new GetFeatureFlagsQuery()) ).Produces(); - internalGroup.MapGet("/{flagKey}/tenants", async Task> (string flagKey, IMediator mediator) - => await mediator.Send(new GetFeatureFlagTenantsQuery { FlagKey = flagKey }) + internalGroup.MapGet("/{flagKey}/tenants", async Task> (string flagKey, [AsParameters] GetFeatureFlagTenantsQuery query, IMediator mediator) + => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); internalGroup.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) @@ -42,8 +42,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) ).DisableAntiforgery(); - internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, string? search, IMediator mediator) - => await mediator.Send(new GetFeatureFlagUsersQuery { FlagKey = flagKey, Search = search }) + internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFeatureFlagUsersQuery query, IMediator mediator) + => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); internalGroup.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 5c3c92395..111850390 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -4,26 +4,56 @@ import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeftIcon } from "lucide-react"; +import { z } from "zod"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; -import { api } from "@/shared/lib/api/client"; +import { api, FeatureFlagAudienceState, SubscriptionPlan, UserRole } from "@/shared/lib/api/client"; -import type { GetFeatureFlagsResponse, GetFeatureFlagTenantsResponse } from "./-components/types"; +import type { GetFeatureFlagsResponse } from "./-components/types"; import { FeatureFlagInfoSection } from "./-components/FeatureFlagInfoSection"; import { getFeatureFlagDescription, getFeatureFlagName } from "./-components/flagLabels"; import { PlanFeatureFlagInfoSection, PlanFeatureFlagTenantsSection } from "./-components/PlanFeatureFlagSections"; import { ScopeIcon } from "./-components/ScopeIcon"; +import { ALL_STATE_FILTER } from "./-components/stateFilter"; import { TenantOverridesSection } from "./-components/TenantOverridesSection"; import { UserOverridesSection } from "./-components/UserOverridesSection"; +const stateFilterSchema = z.enum([ + FeatureFlagAudienceState.Enabled, + FeatureFlagAudienceState.Disabled, + ALL_STATE_FILTER +]); + +const flagKeySearchSchema = z.object({ + tenantsSearch: z.string().optional(), + tenantsPlans: z.array(z.nativeEnum(SubscriptionPlan)).max(10).optional(), + tenantsState: stateFilterSchema.optional(), + tenantsPageOffset: z.number().int().nonnegative().optional(), + usersSearch: z.string().optional(), + usersRoles: z.array(z.nativeEnum(UserRole)).max(10).optional(), + usersState: stateFilterSchema.optional(), + usersPageOffset: z.number().int().nonnegative().optional() +}); + export const Route = createFileRoute("/feature-flags/$flagKey")({ staticData: { trackingTitle: "Feature flag detail" }, + validateSearch: flagKeySearchSchema, component: FeatureFlagDetailPage }); export default function FeatureFlagDetailPage() { const { flagKey } = Route.useParams(); + const { + tenantsSearch, + tenantsPlans, + tenantsState, + tenantsPageOffset, + usersSearch, + usersRoles, + usersState, + usersPageOffset + } = Route.useSearch(); const { data: featureFlagsData, isLoading: isLoadingFeatureFlags } = api.useQuery( "get", @@ -35,18 +65,8 @@ export default function FeatureFlagDetailPage() { const featureFlag = featureFlagsData?.flags?.find((f) => f.key === flagKey); - const { data: tenantsData, isLoading: isLoadingTenants } = api.useQuery( - "get", - "/api/back-office/feature-flags/{flagKey}/tenants", - { params: { path: { flagKey } } }, - { enabled: featureFlag?.scope === "Tenant" } - ) as { - data: GetFeatureFlagTenantsResponse | undefined; - isLoading: boolean; - }; - const isPlanFeatureFlag = featureFlag?.requiredPlan != null; - const isLoading = isLoadingFeatureFlags || (featureFlag?.scope === "Tenant" && isLoadingTenants); + const isLoading = isLoadingFeatureFlags; const featureFlagName = featureFlag ? getFeatureFlagName(featureFlag.key) : flagKey; const description = featureFlag ? getFeatureFlagDescription(featureFlag.key) || featureFlag.description : ""; @@ -82,30 +102,27 @@ export default function FeatureFlagDetailPage() { )} {featureFlag.scope === "Tenant" && isPlanFeatureFlag && ( - + )} {featureFlag.scope === "User" && ( )}
diff --git a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx new file mode 100644 index 000000000..92f56b39f --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx @@ -0,0 +1,130 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { FeatureFlagAudienceState, SubscriptionPlan } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +import type { StateFilter } from "./stateFilter"; + +import { DEFAULT_STATE_FILTER, fromToggleValues, toToggleValues } from "./stateFilter"; + +interface FeatureFlagTenantsToolbarProps { + flagKey: string; + search: string | undefined; + plans: SubscriptionPlan[]; + state: StateFilter | undefined; +} + +export function FeatureFlagTenantsToolbar({ flagKey, search, plans, state }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + tenantsSearch: debouncedSearch || undefined, + tenantsPageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, flagKey, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handleStateChange = (values: string[]) => { + const next = fromToggleValues(values); + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + tenantsState: next === DEFAULT_STATE_FILTER ? undefined : next, + tenantsPageOffset: undefined + }) + }); + }; + + const handlePlansChange = (values: string[]) => { + const next = values as SubscriptionPlan[]; + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + tenantsPlans: next.length === 0 ? undefined : next, + tenantsPageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + + Enabled + + + Disabled + + + + + {[SubscriptionPlan.Premium, SubscriptionPlan.Standard, SubscriptionPlan.Basis].map((value) => ( + + {getSubscriptionPlanLabel(value)} + + ))} + +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagUsersToolbar.tsx b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagUsersToolbar.tsx new file mode 100644 index 000000000..a6e3d41d8 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagUsersToolbar.tsx @@ -0,0 +1,130 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { FeatureFlagAudienceState, UserRole } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +import type { StateFilter } from "./stateFilter"; + +import { DEFAULT_STATE_FILTER, fromToggleValues, toToggleValues } from "./stateFilter"; + +interface FeatureFlagUsersToolbarProps { + flagKey: string; + search: string | undefined; + roles: UserRole[]; + state: StateFilter | undefined; +} + +export function FeatureFlagUsersToolbar({ flagKey, search, roles, state }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + usersSearch: debouncedSearch || undefined, + usersPageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, flagKey, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handleStateChange = (values: string[]) => { + const next = fromToggleValues(values); + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + usersState: next === DEFAULT_STATE_FILTER ? undefined : next, + usersPageOffset: undefined + }) + }); + }; + + const handleRolesChange = (values: string[]) => { + const next = values as UserRole[]; + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + usersRoles: next.length === 0 ? undefined : next, + usersPageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + + Enabled + + + Disabled + + + + + {[UserRole.Owner, UserRole.Admin, UserRole.Member].map((value) => ( + + {getUserRoleLabel(value)} + + ))} + +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx b/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx index fc3ced23b..857838ee2 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/PlanFeatureFlagSections.tsx @@ -2,13 +2,21 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Badge } from "@repo/ui/components/Badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/ui/components/Collapsible"; +import { Skeleton } from "@repo/ui/components/Skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; import { TextField } from "@repo/ui/components/TextField"; import { ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; +import { api, SubscriptionPlan } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + import type { FeatureFlagInfo, FeatureFlagTenantInfo } from "./types"; +// Plan-managed flags display every tenant grouped by plan; the section is not paginated because the plan +// inheritance view needs the full picture at a glance. Cap is high enough for current tenant counts. +const PLAN_TENANT_LIST_CAP = 1000; + export function PlanFeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: FeatureFlagInfo }>) { return (
@@ -35,18 +43,28 @@ export function PlanFeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFl ); } -export function PlanFeatureFlagTenantsSection({ tenants }: Readonly<{ tenants: FeatureFlagTenantInfo[] }>) { +export function PlanFeatureFlagTenantsSection({ flagKey }: Readonly<{ flagKey: string }>) { const [search, setSearch] = useState(""); + const { data, isLoading } = api.useQuery("get", "/api/back-office/feature-flags/{flagKey}/tenants", { + params: { + path: { flagKey }, + query: { + PageSize: PLAN_TENANT_LIST_CAP + } + } + }); + const filtered = useMemo(() => { + const tenants = data?.tenants ?? []; const lowerSearch = search.toLowerCase(); return search ? tenants.filter((tenant) => tenant.name.toLowerCase().includes(lowerSearch) || tenant.id.includes(lowerSearch)) : tenants; - }, [tenants, search]); + }, [data?.tenants, search]); const planGroups = useMemo(() => { - const groupMap = new Map(); + const groupMap = new Map(); for (const tenant of filtered) { const existing = groupMap.get(tenant.plan); if (existing) { @@ -55,7 +73,11 @@ export function PlanFeatureFlagTenantsSection({ tenants }: Readonly<{ tenants: F groupMap.set(tenant.plan, [tenant]); } } - const planOrder: Record = { Premium: 0, Standard: 1, Free: 2 }; + const planOrder: Record = { + [SubscriptionPlan.Premium]: 0, + [SubscriptionPlan.Standard]: 1, + [SubscriptionPlan.Basis]: 2 + }; return [...groupMap.entries()] .sort(([a], [b]) => (planOrder[a] ?? 99) - (planOrder[b] ?? 99)) .map(([plan, members]) => ({ plan, tenants: members })); @@ -83,16 +105,15 @@ export function PlanFeatureFlagTenantsSection({ tenants }: Readonly<{ tenants: F onChange={(value) => setSearch(value)} className="max-w-[20rem]" /> - {isSearching ? ( + {isLoading ? ( +
+ + +
+ ) : isSearching ? ( ) : ( - planGroups.map((group) => ( - - )) + planGroups.map((group) => ) )}
); @@ -138,8 +159,12 @@ function PlanFeatureFlagTenantTable({ ); } -function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tenants: FeatureFlagTenantInfo[] }>) { +function CollapsiblePlanGroup({ + plan, + tenants +}: Readonly<{ plan: SubscriptionPlan; tenants: FeatureFlagTenantInfo[] }>) { const [isOpen, setIsOpen] = useState(true); + const planLabel = getSubscriptionPlanLabel(plan); return ( @@ -148,10 +173,12 @@ function CollapsiblePlanGroup({ label, tenants }: Readonly<{ label: string; tena className={`size-4 text-muted-foreground transition ${isOpen ? "" : "-rotate-90"}`} aria-hidden={true} /> -

{label}

+

+ {planLabel} ({tenants.length}) +

- +
); diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index d8a36f53d..96b56a242 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -1,65 +1,82 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/ui/components/Collapsible"; -import { TextField } from "@repo/ui/components/TextField"; -import { ChevronDown } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { Building2Icon } from "lucide-react"; +import { useCallback } from "react"; -import type { RolloutBucketRange } from "./rolloutBucket"; -import type { FeatureFlagTenantInfo } from "./types"; +import type { SubscriptionPlan } from "@/shared/lib/api/client"; -import { sortBySourceThenRolloutBucket } from "./rolloutBucket"; -import { TenantOverrideTable, type TenantOverrideTableProps } from "./TenantOverrideTable"; +import { api, FeatureFlagAudienceState } from "@/shared/lib/api/client"; -export function TenantOverridesSection({ - flagKey, - featureFlagDescription, - tenants, - showRolloutBucket, - rolloutBucketRange, - isFeatureFlagActive -}: Readonly<{ +import type { StateFilter } from "./stateFilter"; + +import { FeatureFlagTenantsToolbar } from "./FeatureFlagTenantsToolbar"; +import { DEFAULT_STATE_FILTER, ALL_STATE_FILTER, toApiState } from "./stateFilter"; +import { TenantOverrideTable } from "./TenantOverrideTable"; + +interface TenantOverridesSectionProps { flagKey: string; featureFlagDescription: string; - tenants: FeatureFlagTenantInfo[]; showRolloutBucket: boolean; - rolloutBucketRange: RolloutBucketRange | null; isFeatureFlagActive: boolean; -}>) { - const [search, setSearch] = useState(""); + search: string | undefined; + plans: SubscriptionPlan[]; + state: StateFilter | undefined; + pageOffset: number | undefined; +} - const filtered = useMemo(() => { - const lowerSearch = search.toLowerCase(); - return search - ? tenants.filter((tenant) => tenant.name.toLowerCase().includes(lowerSearch) || tenant.id.includes(lowerSearch)) - : tenants; - }, [tenants, search]); +export function TenantOverridesSection({ + flagKey, + featureFlagDescription, + showRolloutBucket, + isFeatureFlagActive, + search, + plans, + state, + pageOffset +}: Readonly) { + const navigate = useNavigate(); - const enabledTenants = useMemo( - () => - sortBySourceThenRolloutBucket( - filtered.filter((t) => t.isEnabled), - (t) => t.source, - (t) => t.rolloutBucket, - "enabled", - rolloutBucketRange - ), - [filtered, rolloutBucketRange] + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/feature-flags/{flagKey}/tenants", + { + params: { + path: { flagKey }, + query: { + Search: search, + Plans: plans.length === 0 ? undefined : plans, + State: toApiState(state), + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } ); - const disabledTenants = useMemo( - () => - sortBySourceThenRolloutBucket( - filtered.filter((t) => !t.isEnabled), - (t) => t.source, - (t) => t.rolloutBucket, - "disabled", - rolloutBucketRange - ), - [filtered, rolloutBucketRange] - ); + const tenants = data?.tenants ?? []; + const totalPages = data?.totalPages ?? 0; + const currentPage = (data?.currentPageOffset ?? 0) + 1; + const effectiveState = state ?? DEFAULT_STATE_FILTER; + const hasFilters = Boolean(search) || plans.length > 0 || effectiveState !== DEFAULT_STATE_FILTER; - const isSearching = search.length > 0; + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + tenantsPageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate, flagKey] + ); return (
@@ -78,70 +95,69 @@ export function TenantOverridesSection({ )}

- setSearch(value)} - className="max-w-[20rem]" - /> - {isSearching ? ( - + + {isLoading && tenants.length === 0 ? ( + + ) : tenants.length === 0 ? ( + ) : ( <> - - + {totalPages > 1 && ( + + )} )}
); } -function CollapsibleTenantGroup({ - label, - ...tableProps -}: Readonly<{ label: string } & Omit>) { - const [isOpen, setIsOpen] = useState(true); +function TenantOverridesSkeleton() { + return ( +
+ + + +
+ ); +} +function TenantOverridesEmpty({ hasFilters, state }: Readonly<{ hasFilters: boolean; state: StateFilter }>) { + const title = + state === FeatureFlagAudienceState.Enabled + ? t`No enabled accounts` + : state === FeatureFlagAudienceState.Disabled + ? t`No disabled accounts` + : state === ALL_STATE_FILTER + ? t`No accounts yet` + : t`No accounts match these filters`; + const description = hasFilters + ? t`Try clearing the search or filters to see more results.` + : t`Accounts will appear here as they become available.`; return ( - - - -

{label}

-
- - {tableProps.tenants.length > 0 ? ( - - ) : ( -

- No accounts in this group. -

- )} -
-
+ + + + + + {title} + {description} + + ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx deleted file mode 100644 index c754e9316..000000000 --- a/application/account/BackOffice/routes/feature-flags/-components/UserEmptyState.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { t } from "@lingui/core/macro"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; -import { Skeleton } from "@repo/ui/components/Skeleton"; -import { SearchIcon } from "lucide-react"; - -export function UserEmptyState({ variant }: Readonly<{ variant: "no-users" | "no-results" | "loading" }>) { - if (variant === "loading") { - return ( -
- - - -
- ); - } - - const title = variant === "no-users" ? t`Search for users` : t`No users found`; - const description = - variant === "no-users" - ? t`Type an email address above to find users and manage their overrides` - : t`Try adjusting your search`; - return ( - - - - - - {title} - {description} - - - ); -} diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 6ff8ed6e2..d47f81bca 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -1,73 +1,82 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/ui/components/Collapsible"; -import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; -import { TextField } from "@repo/ui/components/TextField"; -import { ChevronDown } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { UsersIcon } from "lucide-react"; +import { useCallback } from "react"; -import { api } from "@/shared/lib/api/client"; +import type { UserRole } from "@/shared/lib/api/client"; -import type { RolloutBucketRange } from "./rolloutBucket"; -import type { FeatureFlagUserInfo } from "./types"; +import { api, FeatureFlagAudienceState } from "@/shared/lib/api/client"; -import { sortBySourceThenRolloutBucket } from "./rolloutBucket"; -import { UserEmptyState } from "./UserEmptyState"; -import { UserOverrideRow } from "./UserOverrideRow"; +import type { StateFilter } from "./stateFilter"; -export function UserOverridesSection({ - flagKey, - featureFlagDescription, - showRolloutBucket, - rolloutBucketRange, - isFeatureFlagActive -}: Readonly<{ +import { FeatureFlagUsersToolbar } from "./FeatureFlagUsersToolbar"; +import { DEFAULT_STATE_FILTER, ALL_STATE_FILTER, toApiState } from "./stateFilter"; +import { UserOverridesTable } from "./UserOverridesTable"; + +interface UserOverridesSectionProps { flagKey: string; featureFlagDescription: string; showRolloutBucket: boolean; - rolloutBucketRange: RolloutBucketRange | null; isFeatureFlagActive: boolean; -}>) { - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); + search: string | undefined; + roles: UserRole[]; + state: StateFilter | undefined; + pageOffset: number | undefined; +} - useEffect(() => { - const timer = setTimeout(() => setDebouncedSearch(search), 300); - return () => clearTimeout(timer); - }, [search]); +export function UserOverridesSection({ + flagKey, + featureFlagDescription, + showRolloutBucket, + isFeatureFlagActive, + search, + roles, + state, + pageOffset +}: Readonly) { + const navigate = useNavigate(); - const { data: usersData, isLoading } = api.useQuery( + const { data, isLoading } = api.useQuery( "get", "/api/back-office/feature-flags/{flagKey}/users", { - params: { path: { flagKey }, query: { search: debouncedSearch } } + params: { + path: { flagKey }, + query: { + Search: search, + Roles: roles.length === 0 ? undefined : roles, + State: toApiState(state), + PageOffset: pageOffset + } + } }, - { enabled: debouncedSearch.length > 0 } + { placeholderData: keepPreviousData } ); - const { enabledUsers, disabledUsers } = useMemo(() => { - const all = usersData?.users ?? []; - const enabled = all.filter((u) => u.isEnabled); - const disabled = all.filter((u) => !u.isEnabled); - return { - enabledUsers: sortBySourceThenRolloutBucket( - enabled, - (u) => u.source, - (u) => u.rolloutBucket, - "enabled", - rolloutBucketRange - ), - disabledUsers: sortBySourceThenRolloutBucket( - disabled, - (u) => u.source, - (u) => u.rolloutBucket, - "disabled", - rolloutBucketRange - ) - }; - }, [usersData?.users, rolloutBucketRange]); + const users = data?.users ?? []; + const totalPages = data?.totalPages ?? 0; + const currentPage = (data?.currentPageOffset ?? 0) + 1; + const effectiveState = state ?? DEFAULT_STATE_FILTER; + const hasFilters = Boolean(search) || roles.length > 0 || effectiveState !== DEFAULT_STATE_FILTER; - const hasSearched = debouncedSearch.length > 0; + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + usersPageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate, flagKey] + ); return (
@@ -82,127 +91,73 @@ export function UserOverridesSection({ exclude specific users. ) : ( - Search for users by email and toggle the override switch to enable this feature. + Toggle the override switch to enable this feature for specific users. )}

- setSearch(value)} - className="max-w-[20rem]" - /> - {!hasSearched ? ( - - ) : isLoading ? ( - - ) : enabledUsers.length + disabledUsers.length > 0 ? ( + + {isLoading && users.length === 0 ? ( + + ) : users.length === 0 ? ( + + ) : ( <> - - + {totalPages > 1 && ( + + )} - ) : ( - )}
); } -interface UserTableProps { - ariaLabel: string; - users: FeatureFlagUserInfo[]; - flagKey: string; - featureFlagDescription: string; - showRolloutBucket: boolean; - isFeatureFlagActive: boolean; -} - -function UserTable({ - ariaLabel, - users, - flagKey, - featureFlagDescription, - showRolloutBucket, - isFeatureFlagActive -}: Readonly) { +function UserOverridesSkeleton() { return ( - - - - - User - - - Account - - - Role - - - Last seen - - - Source - - {showRolloutBucket && ( - - Bucket - - )} - - Override - - - - - {users.map((user) => ( - - ))} - -
+
+ + + +
); } -function CollapsibleUserGroup({ - label, - ...tableProps -}: Readonly<{ label: string } & Omit>) { - const [isOpen, setIsOpen] = useState(true); - +function UserOverridesEmpty({ hasFilters, state }: Readonly<{ hasFilters: boolean; state: StateFilter }>) { + const title = + state === FeatureFlagAudienceState.Enabled + ? t`No enabled users` + : state === FeatureFlagAudienceState.Disabled + ? t`No disabled users` + : state === ALL_STATE_FILTER + ? t`No users yet` + : t`No users match these filters`; + const description = hasFilters + ? t`Try clearing the search or filters to see more results.` + : t`Users will appear here as they become available.`; return ( - - - -

{label}

-
- - - -
+ + + + + + {title} + {description} + + ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesTable.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesTable.tsx new file mode 100644 index 000000000..ee5d74c9b --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesTable.tsx @@ -0,0 +1,68 @@ +import { Trans } from "@lingui/react/macro"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; + +import type { FeatureFlagUserInfo } from "./types"; + +import { UserOverrideRow } from "./UserOverrideRow"; + +export interface UserOverridesTableProps { + ariaLabel: string; + users: FeatureFlagUserInfo[]; + flagKey: string; + featureFlagDescription: string; + showRolloutBucket: boolean; + isFeatureFlagActive: boolean; +} + +export function UserOverridesTable({ + ariaLabel, + users, + flagKey, + featureFlagDescription, + showRolloutBucket, + isFeatureFlagActive +}: Readonly) { + return ( + + + + + User + + + Account + + + Role + + + Last seen + + + Source + + {showRolloutBucket && ( + + Bucket + + )} + + Override + + + + + {users.map((user) => ( + + ))} + +
+ ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts index ec03e9d29..f93dc8fdf 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/rolloutBucket.ts @@ -1,7 +1,5 @@ import { t } from "@lingui/core/macro"; -const ROLLOUT_BUCKET_MAX = 100; - export function formatRolloutBucketRange( rolloutBucketStart: number, rolloutBucketEnd: number, @@ -12,41 +10,3 @@ export function formatRolloutBucketRange( } return t`Rollout buckets: ${rolloutBucketStart}-99 and 0-${rolloutBucketEnd} (${rolloutPercentage}%)`; } - -export interface RolloutBucketRange { - bucketStart: number; - bucketEnd: number; -} - -function rolloutBucketSortOrder( - rolloutBucket: number, - group: "enabled" | "disabled", - range: RolloutBucketRange -): number { - const ref = group === "disabled" ? range.bucketEnd : range.bucketStart; - return (rolloutBucket - ref + ROLLOUT_BUCKET_MAX) % ROLLOUT_BUCKET_MAX; -} - -export function sortBySourceThenRolloutBucket( - items: T[], - getSource: (item: T) => string, - getRolloutBucket: (item: T) => number, - group: "enabled" | "disabled", - rolloutBucketRange: RolloutBucketRange | null -): T[] { - return [...items].sort((a, b) => { - const aOrder = - getSource(a) === "manual_override" - ? -1 - : rolloutBucketRange - ? rolloutBucketSortOrder(getRolloutBucket(a), group, rolloutBucketRange) - : 0; - const bOrder = - getSource(b) === "manual_override" - ? -1 - : rolloutBucketRange - ? rolloutBucketSortOrder(getRolloutBucket(b), group, rolloutBucketRange) - : 0; - return aOrder - bOrder; - }); -} diff --git a/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts b/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts new file mode 100644 index 000000000..728e5a724 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts @@ -0,0 +1,31 @@ +import { FeatureFlagAudienceState } from "@/shared/lib/api/client"; + +/** + * URL representation of the state filter for the override sections. + * + * - URL param absent is the fresh-visit default and renders the Enabled chip pressed. + * - `Enabled` / `Disabled` filter the API call to that subset. + * - `All` is the frontend-only sentinel for "user wants both states shown". Both chips render + * pressed and the API call omits `State`. We persist this in the URL (rather than relying on + * transient component state) so refresh and deep-link round-trip the user's selection. + */ +export const ALL_STATE_FILTER = "All"; + +export type StateFilter = FeatureFlagAudienceState | typeof ALL_STATE_FILTER; + +export const DEFAULT_STATE_FILTER: StateFilter = FeatureFlagAudienceState.Enabled; + +export function toApiState(filter: StateFilter | undefined): FeatureFlagAudienceState | undefined { + if (filter === ALL_STATE_FILTER) return undefined; + return filter ?? FeatureFlagAudienceState.Enabled; +} + +export function toToggleValues(filter: StateFilter | undefined): FeatureFlagAudienceState[] { + if (filter === ALL_STATE_FILTER) return [FeatureFlagAudienceState.Enabled, FeatureFlagAudienceState.Disabled]; + return [filter ?? FeatureFlagAudienceState.Enabled]; +} + +export function fromToggleValues(values: string[]): StateFilter { + if (values.length === 1) return values[0] as FeatureFlagAudienceState; + return ALL_STATE_FILTER; +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 65ad035c0..400203dd8 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -26,10 +26,6 @@ export interface GetFeatureFlagsResponse { export type FeatureFlagTenantInfo = components["schemas"]["FeatureFlagTenantInfo"]; -export type GetFeatureFlagTenantsResponse = components["schemas"]["GetFeatureFlagTenantsResponse"]; - export type FeatureFlagUserInfo = components["schemas"]["FeatureFlagUserInfo"]; -export type GetFeatureFlagUsersResponse = components["schemas"]["GetFeatureFlagUsersResponse"]; - export type FeatureFlagSourceLiteral = "manual_override" | "ab_rollout" | "plan" | "default"; diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 827ce9488..76fcf1aa1 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -21,11 +21,6 @@ msgstr "{0, plural, one {# time siden} other {# timer siden}}" msgid "{0, plural, one {# minute ago} other {# minutes ago}}" msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" -#. placeholder {0}: group.plan -#. placeholder {1}: group.tenants.length -msgid "{0} ({1})" -msgstr "{0} ({1})" - #. placeholder {0}: formatCurrency(blended, currency) msgid "{0} blended" msgstr "{0} samlet" @@ -155,6 +150,9 @@ msgstr "Konti inkluderes automatisk baseret på deres udrulningsbucket. Brug til msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." +msgid "Accounts will appear here as they become available." +msgstr "Konti vises her, når de bliver tilgængelige." + msgid "Active" msgstr "Aktiv" @@ -353,11 +351,6 @@ msgstr "Enhed" msgid "Disabled" msgstr "Deaktiveret" -#. placeholder {0}: disabledTenants.length -#. placeholder {0}: disabledUsers.length -msgid "Disabled ({0})" -msgstr "Deaktiveret ({0})" - msgid "Disaster recovery complete" msgstr "Katastrofegendannelse fuldført" @@ -397,11 +390,6 @@ msgstr "E-mail afventer" msgid "Enabled" msgstr "Aktiveret" -#. placeholder {0}: enabledTenants.length -#. placeholder {0}: enabledUsers.length -msgid "Enabled ({0})" -msgstr "Aktiveret ({0})" - #. placeholder {0}: formatTimestamp(featureFlag.enabledAt) #. placeholder {1}: formatTimestamp(featureFlag.disabledAt) msgid "Enabled period: {0} - {1}" @@ -606,8 +594,8 @@ msgstr "Næste" msgid "No account memberships" msgstr "Ingen kontomedlemskaber" -msgid "No accounts in this group." -msgstr "Ingen konti i denne gruppe." +msgid "No accounts match these filters" +msgstr "Ingen konti matcher disse filtre" msgid "No accounts match your filters" msgstr "Ingen konti matcher dine filtre" @@ -633,6 +621,18 @@ msgstr "Ingen faktureringshændelser endnu" msgid "No change" msgstr "Ingen ændring" +msgid "No disabled accounts" +msgstr "Ingen deaktiverede konti" + +msgid "No disabled users" +msgstr "Ingen deaktiverede brugere" + +msgid "No enabled accounts" +msgstr "Ingen aktiverede konti" + +msgid "No enabled users" +msgstr "Ingen aktiverede brugere" + msgid "No feature flags" msgstr "Ingen feature flags" @@ -708,8 +708,8 @@ msgstr "Der er ingen brugeromfattede feature flags defineret." msgid "No users" msgstr "Ingen brugere" -msgid "No users found" -msgstr "Ingen brugere fundet" +msgid "No users match these filters" +msgstr "Ingen brugere matcher disse filtre" msgid "No users match your filters." msgstr "Ingen brugere matcher dine filtre." @@ -1019,8 +1019,8 @@ msgstr "Søg efter kontonavn" msgid "Search by account name or ID" msgstr "Søg efter kontonavn eller ID" -msgid "Search by email" -msgstr "Søg efter e-mail" +msgid "Search by account name or owner email" +msgstr "Søg efter kontonavn eller ejer-e-mail" msgid "Search by email, name, or account" msgstr "Søg på e-mail, navn eller konto" @@ -1031,12 +1031,6 @@ msgstr "Søg efter navn" msgid "Search by name or email" msgstr "Søg efter navn eller e-mail" -msgid "Search for users" -msgstr "Søg efter brugere" - -msgid "Search for users by email and toggle the override switch to enable this feature." -msgstr "Søg efter brugere via e-mail og slå tilsidesættelsesknappen til for at aktivere denne funktion." - msgid "Search results" msgstr "Søgeresultater" @@ -1082,6 +1076,9 @@ msgstr "Kilde" msgid "Standard" msgstr "Standard" +msgid "State" +msgstr "Tilstand" + msgid "Status" msgstr "Status" @@ -1167,6 +1164,9 @@ msgstr "Slå {0} til/fra" msgid "Toggle the override switch to enable this feature for specific accounts." msgstr "Slå tilsidesættelsesknappen til for at aktivere denne funktion for specifikke konti." +msgid "Toggle the override switch to enable this feature for specific users." +msgstr "Skift tilsidesættelse for at aktivere denne funktion for specifikke brugere." + msgid "Total" msgstr "I alt" @@ -1179,9 +1179,6 @@ msgstr "Omsætning i alt" msgid "Try a different search term or clear the role and activity filters." msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." -msgid "Try adjusting your search" -msgstr "Prøv at justere din søgning" - msgid "Try again" msgstr "Prøv igen" @@ -1197,9 +1194,6 @@ msgstr "Afprøv eksperimentelle brugergrænsefladekomponenter" msgid "Type" msgstr "Type" -msgid "Type an email address above to find users and manage their overrides" -msgstr "Indtast en e-mailadresse ovenfor for at finde brugere og styre deres tilsidesættelser" - msgid "Unclassified" msgstr "Uklassificeret" @@ -1239,6 +1233,9 @@ msgstr "Brugere inkluderes automatisk baseret på deres udrulningsbucket. Brug t msgid "Users will appear here as accounts are created." msgstr "Brugere vises her efterhånden som konti oprettes." +msgid "Users will appear here as they become available." +msgstr "Brugere vises her, når de bliver tilgængelige." + msgid "VAT" msgstr "Moms" diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs index 434c0c1e6..85f58f654 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs @@ -13,14 +13,24 @@ namespace Account.Features.FeatureFlags.Queries; [PublicAPI] -public sealed record GetFeatureFlagTenantsQuery : IRequest> +public sealed record GetFeatureFlagTenantsQuery( + string? Search = null, + SubscriptionPlan[]? Plans = null, + FeatureFlagAudienceState? State = null, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> { [JsonIgnore] // Removes from API contract public string FlagKey { get; init; } = null!; + + public string? Search { get; } = Search?.Trim().ToLower(); + + public SubscriptionPlan[] Plans { get; } = Plans ?? []; } [PublicAPI] -public sealed record GetFeatureFlagTenantsResponse(FeatureFlagTenantInfo[] Tenants); +public sealed record GetFeatureFlagTenantsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, FeatureFlagTenantInfo[] Tenants); // Field names mirror TenantSummary so Mapster's convention-based mapping covers the shared subset. Override fields // (RolloutBucket, IsEnabled, Source) come from the feature-flag evaluation and are applied via `with` on top of Adapt. @@ -52,6 +62,11 @@ public GetFeatureFlagTenantsValidator() .NotEmpty().WithMessage("Feature flag key must not be empty.") .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); + + RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters."); + RuleFor(x => x.Plans.Length).LessThanOrEqualTo(10).WithMessage("Plans filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); } } @@ -67,7 +82,15 @@ public async Task> Handle(GetFeatureFlagTe var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); + // Load all tenants then narrow in memory: search must match tenant name OR owner email, which can't be expressed + // by the existing SearchAllTenantsAsync (name + id only). Tenant counts in back-office are bounded. var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + + if (query.Plans.Length > 0) + { + tenants = tenants.Where(t => query.Plans.Contains(t.Plan)).ToArray(); + } + var tenantIds = tenants.Select(t => t.Id).ToArray(); var subscriptions = tenantIds.Length == 0 @@ -79,6 +102,14 @@ public async Task> Handle(GetFeatureFlagTe ? new Dictionary() : await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + if (!string.IsNullOrEmpty(query.Search)) + { + tenants = tenants.Where(t => + t.Name.ToLowerInvariant().Contains(query.Search) || + (ownerByTenantId.TryGetValue(t.Id, out var owner) && owner.Email.Contains(query.Search)) + ).ToArray(); + } + var tenantOverrides = await featureFlagRepository.GetTenantOverridesForFlagAsync(query.FlagKey, cancellationToken); var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!.Value); @@ -101,7 +132,25 @@ public async Task> Handle(GetFeatureFlagTe } ).ToArray(); - return new GetFeatureFlagTenantsResponse(featureFlagTenants); + var filtered = query.State switch + { + FeatureFlagAudienceState.Enabled => featureFlagTenants.Where(t => t.IsEnabled).ToArray(), + FeatureFlagAudienceState.Disabled => featureFlagTenants.Where(t => !t.IsEnabled).ToArray(), + _ => featureFlagTenants + }; + + var ordered = filtered.OrderBy(t => t.Name).ToArray(); + + var totalCount = ordered.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new GetFeatureFlagTenantsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); } private static (bool IsEnabled, string Source) EvaluateOverride( diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs index 30d053cc9..f6ffdb26c 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs @@ -1,6 +1,7 @@ using Account.Features.FeatureFlags.Domain; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; +using Account.Features.Users.BackOffice.Queries; using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; @@ -8,20 +9,29 @@ using SharedKernel.Cqrs; using SharedKernel.Domain; using SharedKernel.FeatureFlags; +using SharedKernel.Persistence; namespace Account.Features.FeatureFlags.Queries; [PublicAPI] -public sealed record GetFeatureFlagUsersQuery : IRequest> +public sealed record GetFeatureFlagUsersQuery( + string? Search = null, + UserRole[]? Roles = null, + FeatureFlagAudienceState? State = null, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> { [JsonIgnore] // Removes from API contract public string FlagKey { get; init; } = null!; - public string? Search { get; init; } + public string? Search { get; } = Search?.Trim().ToLower(); + + public UserRole[] Roles { get; } = Roles ?? []; } [PublicAPI] -public sealed record GetFeatureFlagUsersResponse(FeatureFlagUserInfo[] Users); +public sealed record GetFeatureFlagUsersResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, FeatureFlagUserInfo[] Users); // Field names mirror the User aggregate so Mapster's convention-based mapping covers the user subset. Tenant-derived // fields (TenantName, TenantPlan) and override fields (RolloutBucket, IsEnabled, Source) are applied via `with`. @@ -53,10 +63,13 @@ public GetFeatureFlagUsersValidator() .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.User).WithMessage("Feature flag must have user scope."); RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters."); + RuleFor(x => x.Roles.Length).LessThanOrEqualTo(10).WithMessage("Roles filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); } } -public sealed class GetFeatureFlagUsersHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, ITenantRepository tenantRepository) +public sealed class GetFeatureFlagUsersHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, ITenantRepository tenantRepository, TimeProvider timeProvider) : IRequestHandler> { public async Task> Handle(GetFeatureFlagUsersQuery query, CancellationToken cancellationToken) @@ -64,9 +77,18 @@ public async Task> Handle(GetFeatureFlagUser var definition = SharedKernel.FeatureFlags.FeatureFlags.Get(query.FlagKey); if (definition is null) return Result.NotFound($"Feature flag with key '{query.FlagKey}' not found."); - if (string.IsNullOrWhiteSpace(query.Search)) return new GetFeatureFlagUsersResponse([]); + var (users, _, _) = await userRepository.SearchAllUsersUnfilteredAsync( + query.Search ?? string.Empty, + query.Roles, + null, + timeProvider.GetUtcNow(), + SortableBackOfficeUserProperties.Name, + SortOrder.Ascending, + 0, + int.MaxValue, + cancellationToken + ); - var users = await userRepository.SearchByEmailUnfilteredAsync(query.Search.Trim(), cancellationToken); var userOverrides = await featureFlagRepository.GetUserOverridesForFlagAsync(query.FlagKey, cancellationToken); var overridesByUserId = userOverrides.ToDictionary(f => f.UserId!); @@ -92,7 +114,24 @@ public async Task> Handle(GetFeatureFlagUser } ).ToArray(); - return new GetFeatureFlagUsersResponse(featureFlagUsers); + // featureFlagUsers is already name-ascending from SearchAllUsersUnfilteredAsync; state filtering preserves order. + var ordered = query.State switch + { + FeatureFlagAudienceState.Enabled => featureFlagUsers.Where(u => u.IsEnabled).ToArray(), + FeatureFlagAudienceState.Disabled => featureFlagUsers.Where(u => !u.IsEnabled).ToArray(), + _ => featureFlagUsers + }; + + var totalCount = ordered.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new GetFeatureFlagUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); } private static (bool IsEnabled, string Source) EvaluateOverride( diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 006b44ac9..537e147f1 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -4,7 +4,10 @@ using Account.Features.FeatureFlags.Commands; using Account.Features.FeatureFlags.Domain; using Account.Features.FeatureFlags.Queries; +using Account.Features.Subscriptions.Domain; +using Account.Features.Users.Domain; using FluentAssertions; +using SharedKernel.Domain; using SharedKernel.FeatureFlags; using SharedKernel.Tests; using SharedKernel.Tests.Persistence; @@ -470,7 +473,7 @@ public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldRetur } [Fact] - public async Task GetFeatureFlagUsers_WhenNoSearchProvided_ShouldReturnEmptyArray() + public async Task GetFeatureFlagUsers_WhenNoSearchProvided_ShouldReturnAllUsersPaginated() { // Arrange var flagKey = "compact-view"; @@ -482,7 +485,11 @@ public async Task GetFeatureFlagUsers_WhenNoSearchProvided_ShouldReturnEmptyArra response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Users.Should().BeEmpty(); + result.Users.Should().NotBeEmpty(); + result.TotalCount.Should().BeGreaterThan(0); + result.PageSize.Should().Be(25); + result.CurrentPageOffset.Should().Be(0); + result.TotalPages.Should().BeGreaterThan(0); } [Fact] @@ -884,6 +891,311 @@ public async Task GetFeatureFlagTenants_WhenSystemScopedFlag_ShouldReturnBadRequ response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + // Pagination + filtering tests + + [Fact] + public async Task GetFeatureFlagTenants_WhenSearchMatchesOwnerEmail_ShouldReturnMatchingTenants() + { + // Arrange + var flagKey = "sso"; + + // Act - DatabaseSeeder.Tenant1Owner email is "owner@tenant-1.com", so search by "tenant-1" hits owner email + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Search=tenant-1"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotBeEmpty(); + result.Tenants.Should().OnlyContain(t => t.Owner!.Email.Contains("tenant-1")); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenSearchHasNoMatches_ShouldReturnEmpty() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Search=does-not-exist-anywhere"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenPlansFilterMatches_ShouldReturnOnlyTenantsOnSelectedPlans() + { + // Arrange - DatabaseSeeder.Tenant1 is on the Basis plan + var flagKey = "sso"; + + // Act + var matchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Plans=Basis"); + var noMatchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Plans=Premium"); + + // Assert + matchResponse.ShouldBeSuccessfulGetRequest(); + noMatchResponse.ShouldBeSuccessfulGetRequest(); + var matchResult = await matchResponse.DeserializeResponse(); + var noMatchResult = await noMatchResponse.DeserializeResponse(); + matchResult.Should().NotBeNull(); + noMatchResult.Should().NotBeNull(); + matchResult.Tenants.Should().OnlyContain(t => t.Plan == SubscriptionPlan.Basis); + matchResult.TotalCount.Should().BeGreaterThan(0); + noMatchResult.Tenants.Should().BeEmpty(); + noMatchResult.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenStateEnabledAndTenantHasManualOverride_ShouldReturnOnlyEnabledTenants() + { + // Arrange - tenant-1 gets a manual enable for `sso`; all other tenants remain disabled by default + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, enabled: true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Enabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.IsEnabled); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenStateDisabledAndTenantIsEnabledViaOverride_ShouldExcludeTenant() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, enabled: true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Disabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotContain(t => t.Id.Value == tenantId); + result.Tenants.Where(t => t.IsEnabled).Should().BeEmpty(); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenStateOmitted_ShouldNotFilterByState() + { + // Arrange - tenant-1 gets enabled, then ask without State (omitted = no filter) + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, enabled: true); + + // Act + var omittedResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + var enabledResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Enabled"); + + // Assert + omittedResponse.ShouldBeSuccessfulGetRequest(); + enabledResponse.ShouldBeSuccessfulGetRequest(); + var omitted = await omittedResponse.DeserializeResponse(); + var enabled = await enabledResponse.DeserializeResponse(); + omitted.Should().NotBeNull(); + enabled.Should().NotBeNull(); + omitted.TotalCount.Should().BeGreaterThanOrEqualTo(enabled.TotalCount); + omitted.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenPaginated_ShouldReturnCorrectPagingMetadata() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?PageSize=1&PageOffset=0"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.PageSize.Should().Be(1); + result.CurrentPageOffset.Should().Be(0); + result.Tenants.Length.Should().BeLessOrEqualTo(1); + result.TotalPages.Should().Be(result.TotalCount); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenSortedByName_ShouldReturnTenantsInAscendingNameOrder() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().BeInAscendingOrder(t => t.Name); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenSearchMatchesEmail_ShouldReturnOnlyMatchingUsers() + { + // Arrange + var flagKey = "compact-view"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?Search=owner@tenant-1"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + result.Users.Should().OnlyContain(u => u.Email.Contains("owner@tenant-1")); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenRolesFilterApplied_ShouldReturnOnlyUsersWithSelectedRoles() + { + // Arrange + var flagKey = "compact-view"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?Roles=Owner"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + result.Users.Should().OnlyContain(u => u.Role == UserRole.Owner); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenStateEnabledAndUserHasManualOverride_ShouldReturnOnlyEnabledUsers() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertUserOverride(flagKey, tenantId, userId, enabled: true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?State=Enabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().OnlyContain(u => u.IsEnabled); + result.Users.Should().Contain(u => u.Id.Value == userId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenStateDisabled_ShouldReturnOnlyDisabledUsers() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertUserOverride(flagKey, tenantId, userId, enabled: true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?State=Disabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().OnlyContain(u => !u.IsEnabled); + result.Users.Should().NotContain(u => u.Id.Value == userId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenPaginated_ShouldReturnCorrectPagingMetadata() + { + // Arrange + var flagKey = "compact-view"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?PageSize=1&PageOffset=0"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.PageSize.Should().Be(1); + result.CurrentPageOffset.Should().Be(0); + result.Users.Length.Should().BeLessOrEqualTo(1); + result.TotalPages.Should().Be(result.TotalCount); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenPageOffsetExceedsTotalPages_ShouldReturnBadRequest() + { + // Arrange + var flagKey = "compact-view"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?PageOffset=100"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + private void InsertTenantOverride(string flagKey, TenantId tenantId, bool enabled) + { + var overrideId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", null), + ("enabled_at", enabled ? now : null), + ("disabled_at", enabled ? null : now), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + } + + private void InsertUserOverride(string flagKey, TenantId tenantId, string userId, bool enabled) + { + var overrideId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", userId), + ("enabled_at", enabled ? now : null), + ("disabled_at", enabled ? null : now), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + } + // Query tests [Fact] diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index ff7c80630..c9ce65f15 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -12,7 +12,7 @@ test.describe("@smoke", () => { * * Tests the full feature flag management flow: * - Back-office flag list: view flags grouped by scope (Account, Plan, User, System) - * - Back-office flag detail: navigate into account-scoped flag, toggle account override twice, set A/B rollout percentage + * - Back-office flag detail: navigate into account-scoped flag, search by name, toggle override pair, set A/B rollout percentage * - Account settings: verify Features section, toggle account-scoped custom branding flag * - User preferences: verify Beta features section, toggle user-scoped compact view flag */ @@ -66,27 +66,39 @@ test.describe("@smoke", () => { await expect(page.getByRole("heading", { name: "Account status" })).toBeVisible(); })(); - await step("Search for an account & verify search results table appears")(async () => { - await page.getByPlaceholder("Search by account name or ID").fill("test"); + await step("Pin beta-features rollout to 100 via back-office API & verify tenants evaluate enabled")(async () => { + const response = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, + { data: { rolloutPercentage: 100 } } + ); + + expect(response.ok()).toBe(true); + })(); - await expect(page.getByRole("table", { name: "Search results" })).toBeVisible(); + await step( + "Search by Test Organization & verify the URL reflects the debounced search term and the table re-renders" + )(async () => { + await page.getByRole("searchbox", { name: "Search" }).fill("Test Organization"); + + await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); + await expect(page.getByRole("table", { name: "Accounts" })).toBeVisible(); })(); - await step("Toggle account override & verify toast confirms state change")(async () => { - const overrideSwitch = page.getByRole("table", { name: "Search results" }).getByRole("switch").first(); + await step("Toggle the first account override & verify toast confirms state change")(async () => { + const overrideSwitch = page.getByRole("table", { name: "Accounts" }).getByRole("switch").first(); await overrideSwitch.click(); await expectToastMessage(context, "Beta features"); })(); - await step("Toggle account override back & verify toast confirms state change")(async () => { - const overrideSwitch = page.getByRole("table", { name: "Search results" }).getByRole("switch").first(); + await step("Toggle the same account override back & verify toast confirms state change")(async () => { + const overrideSwitch = page.getByRole("table", { name: "Accounts" }).getByRole("switch").first(); await overrideSwitch.click(); await expectToastMessage(context, "Beta features"); })(); - await step("Set A/B rollout percentage & verify success toast on blur")(async () => { + await step("Set A/B rollout percentage via the spinbutton & verify success toast on blur")(async () => { const percentageInput = page.getByRole("spinbutton", { name: "Rollout %" }); await percentageInput.fill(String((Date.now() % 99) + 1)); await blurActiveElement(page); @@ -152,3 +164,186 @@ test.describe("@smoke", () => { })(); }); }); + +test.describe("@comprehensive", () => { + /** + * FEATURE FLAG DETAIL FILTER & PAGINATION E2E TEST + * + * Tests the toolbar and pagination on /feature-flags/{flagKey}. Preconditions + * pin beta-features rollout to 100 via the back-office API so every tenant + * evaluates enabled regardless of cold-DB state, then restore it at the end. + * - Tenants tab: default load has the URL bare and the Enabled chip pressed + * - Tenants tab: clicking Disabled from default switches the URL to tenantsState=All + * - Tenants tab: reloading from `?tenantsState=All` shows BOTH chips pressed + * - Tenants tab: clicking Enabled from the All view yields tenantsState=Disabled with only Disabled pressed + * - Tenants tab: search debounces and narrows the visible rows via tenantsSearch + * - Tenants tab: plan chip toggles tenantsPlans=["Premium"] and unpresses when toggled off + * - Tenants tab: pagination renders when totalPages > 1 and Next advances tenantsPageOffset + * - Users tab (compact-view flag): role chip filters down to usersRoles=["Owner"] + */ + test("should filter and paginate tenants and users on the feature flag detail page", async ({ browser }) => { + const backOfficeContext = await browser.newContext({ baseURL: BACK_OFFICE_BASE_URL, ignoreHTTPSErrors: true }); + const page = await backOfficeContext.newPage(); + createTestContext(page); + + await step("Log in as Admin via MockEasyAuth & verify redirect to beta-features detail")(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + + await expect(page.getByRole("radio", { name: "Admin Log in with admin rights" })).toBeVisible(); + await page.getByRole("radio", { name: "Admin Log in with admin rights" }).click(); + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + await expect(page.getByRole("heading", { name: "Account status" })).toBeVisible(); + })(); + + await step("Pin beta-features rollout to 100 via back-office API & verify tenants evaluate enabled")(async () => { + const response = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, + { data: { rolloutPercentage: 100 } } + ); + + expect(response.ok()).toBe(true); + })(); + + // === TENANTS: STATE CHIPS (multi-select; default Enabled pressed; All sentinel renders both pressed) === + + const accountsTable = page.getByRole("table", { name: "Accounts" }); + const stateGroup = page.getByRole("group", { name: "State" }); + const enabledChip = stateGroup.getByRole("button", { name: "Enabled" }); + const disabledChip = stateGroup.getByRole("button", { name: "Disabled" }); + + await step("Reload bare URL & verify Enabled chip is pressed by default and Disabled chip is unpressed")( + async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + + await expect(enabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(disabledChip).toHaveAttribute("aria-pressed", "false"); + await expect(accountsTable).toBeVisible(); + } + )(); + + await step("Click Disabled chip from default & verify URL switches to the All state and the table shows all rows")( + async () => { + await disabledChip.click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=All`); + await expect(accountsTable).toBeVisible(); + } + )(); + + await step("Reload from the All URL & verify both chips appear pressed")(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=All`); + + await expect(enabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(disabledChip).toHaveAttribute("aria-pressed", "true"); + })(); + + await step("Click Enabled chip from the All state & verify only Disabled remains pressed and URL is Disabled")( + async () => { + await enabledChip.click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=Disabled`); + await expect(disabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(enabledChip).toHaveAttribute("aria-pressed", "false"); + } + )(); + + // === TENANTS: SEARCH === + + const searchBox = page.getByRole("searchbox", { name: "Search" }); + + await step("Reload bare URL & verify default Enabled state and search box is empty")(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + + await expect(enabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(searchBox).toHaveValue(""); + })(); + + await step("Type into search box & verify URL contains debounced search term and the table re-renders")( + async () => { + await searchBox.fill("Test Organization"); + + await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); + await expect(accountsTable).toBeVisible(); + } + )(); + + await step("Clear search via Clear search button & verify URL drops the search param")(async () => { + await page.getByRole("button", { name: "Clear search" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + })(); + + // === TENANTS: PLAN FILTER === + + const premiumChip = page.getByRole("group", { name: "Plan" }).getByRole("button", { name: "Premium" }); + + await step("Click Premium plan chip & verify URL serializes the plan array and the chip is pressed")(async () => { + await premiumChip.click(); + + await expect(page).toHaveURL((url) => url.searchParams.get("tenantsPlans") === '["Premium"]'); + await expect(premiumChip).toHaveAttribute("aria-pressed", "true"); + })(); + + await step("Click Premium plan chip again & verify chip is unpressed and the URL drops the plan filter")( + async () => { + await premiumChip.click(); + + await expect(premiumChip).toHaveAttribute("aria-pressed", "false"); + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + } + )(); + + // === TENANTS: PAGINATION === + // Rollout=100 (set above) ensures the default Enabled view contains every dev-DB tenant, which + // reliably exceeds the 25-row PageSize and renders the Next button. + + await step("Click Next page in the default Enabled view & verify URL advances and the table re-renders")( + async () => { + const nextPageButton = page.getByRole("button", { name: "Next" }); + await expect(nextPageButton).toBeVisible(); + await nextPageButton.click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsPageOffset=1`); + await expect(accountsTable).toBeVisible(); + } + )(); + + // === USERS: ROLE FILTER === + + await step("Navigate to compact-view user-scoped flag & verify default Enabled chip and Users table render")( + async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/compact-view`); + + await expect(page.getByRole("heading", { name: "User status" })).toBeVisible(); + await expect(page.getByRole("table", { name: "Users" })).toBeVisible(); + await expect( + page.getByRole("group", { name: "State" }).getByRole("button", { name: "Enabled" }) + ).toHaveAttribute("aria-pressed", "true"); + } + )(); + + const ownerChip = page.getByRole("group", { name: "Role" }).getByRole("button", { name: "Owner" }); + + await step("Click Owner role chip & verify URL serializes the role array and the chip is pressed")(async () => { + await ownerChip.click(); + + await expect(page).toHaveURL((url) => url.searchParams.get("usersRoles") === '["Owner"]'); + await expect(ownerChip).toHaveAttribute("aria-pressed", "true"); + })(); + + // === CLEANUP: reset beta-features rollout so the rest of the suite sees the pre-test state === + + await step("Reset beta-features rollout to 0 via back-office API & verify success")(async () => { + const response = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, + { data: { rolloutPercentage: 0 } } + ); + + expect(response.ok()).toBe(true); + })(); + + await backOfficeContext.close(); + }); +}); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAudienceState.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAudienceState.cs new file mode 100644 index 000000000..416a2d39b --- /dev/null +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagAudienceState.cs @@ -0,0 +1,9 @@ +namespace SharedKernel.FeatureFlags; + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FeatureFlagAudienceState +{ + Enabled, + Disabled +} From 5c1335bc8dc52f4e1765978f97426e59930f4b74 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 11 May 2026 21:44:15 +0200 Subject: [PATCH 050/155] Add HasOverride filter and 3-chip state toggle to feature-flag detail page --- .../routes/feature-flags/$flagKey.tsx | 6 + .../-components/FeatureFlagTenantsToolbar.tsx | 46 ++++- .../-components/FeatureFlagUsersToolbar.tsx | 46 ++++- .../-components/TenantOverridesSection.tsx | 13 +- .../-components/UserOverridesSection.tsx | 13 +- .../feature-flags/-components/stateFilter.ts | 16 +- .../shared/translations/locale/da-DK.po | 3 + .../Queries/GetFeatureFlagTenants.cs | 6 + .../Queries/GetFeatureFlagUsers.cs | 14 +- .../GetFeatureFlagTenantsBackOfficeTests.cs | 90 +++++++++ .../Tests/FeatureFlags/FeatureFlagTests.cs | 174 +++++++++++++++++- 11 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 111850390..cccc17677 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -29,10 +29,12 @@ const flagKeySearchSchema = z.object({ tenantsSearch: z.string().optional(), tenantsPlans: z.array(z.nativeEnum(SubscriptionPlan)).max(10).optional(), tenantsState: stateFilterSchema.optional(), + tenantsHasOverride: z.boolean().optional(), tenantsPageOffset: z.number().int().nonnegative().optional(), usersSearch: z.string().optional(), usersRoles: z.array(z.nativeEnum(UserRole)).max(10).optional(), usersState: stateFilterSchema.optional(), + usersHasOverride: z.boolean().optional(), usersPageOffset: z.number().int().nonnegative().optional() }); @@ -48,10 +50,12 @@ export default function FeatureFlagDetailPage() { tenantsSearch, tenantsPlans, tenantsState, + tenantsHasOverride, tenantsPageOffset, usersSearch, usersRoles, usersState, + usersHasOverride, usersPageOffset } = Route.useSearch(); @@ -107,6 +111,7 @@ export default function FeatureFlagDetailPage() { search={tenantsSearch} plans={tenantsPlans ?? []} state={tenantsState} + hasOverride={tenantsHasOverride ?? false} pageOffset={tenantsPageOffset} /> )} @@ -122,6 +127,7 @@ export default function FeatureFlagDetailPage() { search={usersSearch} roles={usersRoles ?? []} state={usersState} + hasOverride={usersHasOverride ?? false} pageOffset={usersPageOffset} /> )} diff --git a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx index 92f56b39f..b74fe3797 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagTenantsToolbar.tsx @@ -12,16 +12,25 @@ import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; import type { StateFilter } from "./stateFilter"; -import { DEFAULT_STATE_FILTER, fromToggleValues, toToggleValues } from "./stateFilter"; +import { ALL_STATE_FILTER, DEFAULT_STATE_FILTER } from "./stateFilter"; interface FeatureFlagTenantsToolbarProps { flagKey: string; search: string | undefined; plans: SubscriptionPlan[]; state: StateFilter | undefined; + hasOverride: boolean; } -export function FeatureFlagTenantsToolbar({ flagKey, search, plans, state }: Readonly) { +const HAS_OVERRIDE_VALUE = "true"; + +export function FeatureFlagTenantsToolbar({ + flagKey, + search, + plans, + state, + hasOverride +}: Readonly) { const navigate = useNavigate(); const [searchInput, setSearchInput] = useState(search ?? ""); const debouncedSearch = useDebounce(searchInput, 500); @@ -46,7 +55,9 @@ export function FeatureFlagTenantsToolbar({ flagKey, search, plans, state }: Rea }, [search]); const handleStateChange = (values: string[]) => { - const next = fromToggleValues(values); + // Single-select with multi-select "last item wins" — keep the most recently clicked chip pressed. + // Clicking the already-pressed chip would otherwise deselect to []; default back to Enabled so one chip is always on. + const next = (values[values.length - 1] as StateFilter | undefined) ?? DEFAULT_STATE_FILTER; navigate({ to: "/feature-flags/$flagKey", params: { flagKey }, @@ -58,6 +69,18 @@ export function FeatureFlagTenantsToolbar({ flagKey, search, plans, state }: Rea }); }; + const handleHasOverrideChange = (values: string[]) => { + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + tenantsHasOverride: values.length > 0 ? true : undefined, + tenantsPageOffset: undefined + }) + }); + }; + const handlePlansChange = (values: string[]) => { const next = values as SubscriptionPlan[]; navigate({ @@ -101,9 +124,12 @@ export function FeatureFlagTenantsToolbar({ flagKey, search, plans, state }: Rea variant="outline" aria-label={t`State`} multiple={true} - value={toToggleValues(state)} + value={[state ?? DEFAULT_STATE_FILTER]} onValueChange={handleStateChange} > + + All + Enabled @@ -112,6 +138,18 @@ export function FeatureFlagTenantsToolbar({ flagKey, search, plans, state }: Rea + + + Has override + + + ) { +const HAS_OVERRIDE_VALUE = "true"; + +export function FeatureFlagUsersToolbar({ + flagKey, + search, + roles, + state, + hasOverride +}: Readonly) { const navigate = useNavigate(); const [searchInput, setSearchInput] = useState(search ?? ""); const debouncedSearch = useDebounce(searchInput, 500); @@ -46,7 +55,9 @@ export function FeatureFlagUsersToolbar({ flagKey, search, roles, state }: Reado }, [search]); const handleStateChange = (values: string[]) => { - const next = fromToggleValues(values); + // Single-select with multi-select "last item wins" — keep the most recently clicked chip pressed. + // Clicking the already-pressed chip would otherwise deselect to []; default back to Enabled so one chip is always on. + const next = (values[values.length - 1] as StateFilter | undefined) ?? DEFAULT_STATE_FILTER; navigate({ to: "/feature-flags/$flagKey", params: { flagKey }, @@ -58,6 +69,18 @@ export function FeatureFlagUsersToolbar({ flagKey, search, roles, state }: Reado }); }; + const handleHasOverrideChange = (values: string[]) => { + navigate({ + to: "/feature-flags/$flagKey", + params: { flagKey }, + search: (previous) => ({ + ...previous, + usersHasOverride: values.length > 0 ? true : undefined, + usersPageOffset: undefined + }) + }); + }; + const handleRolesChange = (values: string[]) => { const next = values as UserRole[]; navigate({ @@ -101,9 +124,12 @@ export function FeatureFlagUsersToolbar({ flagKey, search, roles, state }: Reado variant="outline" aria-label={t`State`} multiple={true} - value={toToggleValues(state)} + value={[state ?? DEFAULT_STATE_FILTER]} onValueChange={handleStateChange} > + + All + Enabled @@ -112,6 +138,18 @@ export function FeatureFlagUsersToolbar({ flagKey, search, roles, state }: Reado + + + Has override + + + ) { const navigate = useNavigate(); @@ -51,6 +53,7 @@ export function TenantOverridesSection({ Search: search, Plans: plans.length === 0 ? undefined : plans, State: toApiState(state), + HasOverride: hasOverride ? true : undefined, PageOffset: pageOffset } } @@ -62,7 +65,7 @@ export function TenantOverridesSection({ const totalPages = data?.totalPages ?? 0; const currentPage = (data?.currentPageOffset ?? 0) + 1; const effectiveState = state ?? DEFAULT_STATE_FILTER; - const hasFilters = Boolean(search) || plans.length > 0 || effectiveState !== DEFAULT_STATE_FILTER; + const hasFilters = Boolean(search) || plans.length > 0 || effectiveState !== DEFAULT_STATE_FILTER || hasOverride; const handlePageChange = useCallback( (page: number) => { @@ -95,7 +98,13 @@ export function TenantOverridesSection({ )}

- + {isLoading && tenants.length === 0 ? ( ) : tenants.length === 0 ? ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index d47f81bca..865b72a3b 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -26,6 +26,7 @@ interface UserOverridesSectionProps { search: string | undefined; roles: UserRole[]; state: StateFilter | undefined; + hasOverride: boolean; pageOffset: number | undefined; } @@ -37,6 +38,7 @@ export function UserOverridesSection({ search, roles, state, + hasOverride, pageOffset }: Readonly) { const navigate = useNavigate(); @@ -51,6 +53,7 @@ export function UserOverridesSection({ Search: search, Roles: roles.length === 0 ? undefined : roles, State: toApiState(state), + HasOverride: hasOverride ? true : undefined, PageOffset: pageOffset } } @@ -62,7 +65,7 @@ export function UserOverridesSection({ const totalPages = data?.totalPages ?? 0; const currentPage = (data?.currentPageOffset ?? 0) + 1; const effectiveState = state ?? DEFAULT_STATE_FILTER; - const hasFilters = Boolean(search) || roles.length > 0 || effectiveState !== DEFAULT_STATE_FILTER; + const hasFilters = Boolean(search) || roles.length > 0 || effectiveState !== DEFAULT_STATE_FILTER || hasOverride; const handlePageChange = useCallback( (page: number) => { @@ -95,7 +98,13 @@ export function UserOverridesSection({ )}

- + {isLoading && users.length === 0 ? ( ) : users.length === 0 ? ( diff --git a/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts b/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts index 728e5a724..920d9ad86 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/stateFilter.ts @@ -1,13 +1,11 @@ import { FeatureFlagAudienceState } from "@/shared/lib/api/client"; /** - * URL representation of the state filter for the override sections. + * URL representation of the single-select state filter on the override toolbars. * * - URL param absent is the fresh-visit default and renders the Enabled chip pressed. + * - `All` shows all rows: API call omits `State`, UI marks the All chip pressed. * - `Enabled` / `Disabled` filter the API call to that subset. - * - `All` is the frontend-only sentinel for "user wants both states shown". Both chips render - * pressed and the API call omits `State`. We persist this in the URL (rather than relying on - * transient component state) so refresh and deep-link round-trip the user's selection. */ export const ALL_STATE_FILTER = "All"; @@ -19,13 +17,3 @@ export function toApiState(filter: StateFilter | undefined): FeatureFlagAudience if (filter === ALL_STATE_FILTER) return undefined; return filter ?? FeatureFlagAudienceState.Enabled; } - -export function toToggleValues(filter: StateFilter | undefined): FeatureFlagAudienceState[] { - if (filter === ALL_STATE_FILTER) return [FeatureFlagAudienceState.Enabled, FeatureFlagAudienceState.Disabled]; - return [filter ?? FeatureFlagAudienceState.Enabled]; -} - -export function fromToggleValues(values: string[]): StateFilter { - if (values.length === 1) return values[0] as FeatureFlagAudienceState; - return ALL_STATE_FILTER; -} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 76fcf1aa1..a545099da 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -462,6 +462,9 @@ msgstr "Google" msgid "Google OAuth" msgstr "Google OAuth" +msgid "Has override" +msgstr "Med tilsidesættelse" + msgid "Hide details" msgstr "Skjul detaljer" diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs index 85f58f654..63e794113 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs @@ -17,6 +17,7 @@ public sealed record GetFeatureFlagTenantsQuery( string? Search = null, SubscriptionPlan[]? Plans = null, FeatureFlagAudienceState? State = null, + bool? HasOverride = null, int PageOffset = 0, int PageSize = 25 ) : IRequest> @@ -139,6 +140,11 @@ public async Task> Handle(GetFeatureFlagTe _ => featureFlagTenants }; + if (query.HasOverride == true) + { + filtered = filtered.Where(t => t.Source == "manual_override").ToArray(); + } + var ordered = filtered.OrderBy(t => t.Name).ToArray(); var totalCount = ordered.Length; diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs index f6ffdb26c..a772da334 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs @@ -18,6 +18,7 @@ public sealed record GetFeatureFlagUsersQuery( string? Search = null, UserRole[]? Roles = null, FeatureFlagAudienceState? State = null, + bool? HasOverride = null, int PageOffset = 0, int PageSize = 25 ) : IRequest> @@ -114,22 +115,27 @@ public async Task> Handle(GetFeatureFlagUser } ).ToArray(); - // featureFlagUsers is already name-ascending from SearchAllUsersUnfilteredAsync; state filtering preserves order. - var ordered = query.State switch + // featureFlagUsers is already name-ascending from SearchAllUsersUnfilteredAsync; subsequent filters preserve order. + var filtered = query.State switch { FeatureFlagAudienceState.Enabled => featureFlagUsers.Where(u => u.IsEnabled).ToArray(), FeatureFlagAudienceState.Disabled => featureFlagUsers.Where(u => !u.IsEnabled).ToArray(), _ => featureFlagUsers }; - var totalCount = ordered.Length; + if (query.HasOverride == true) + { + filtered = filtered.Where(u => u.Source == "manual_override").ToArray(); + } + + var totalCount = filtered.Length; var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; if (query.PageOffset > 0 && query.PageOffset >= totalPages) { return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); } - var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + var paged = filtered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); return new GetFeatureFlagUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); } diff --git a/application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs new file mode 100644 index 000000000..62c0fa282 --- /dev/null +++ b/application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Features.FeatureFlags.Domain; +using Account.Features.FeatureFlags.Queries; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.BackOffice.FeatureFlags; + +public sealed class GetFeatureFlagTenantsBackOfficeTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueOnBackOfficeRoute_ShouldReturnOnlyTenantsWithManualOverride() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var overrideId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", null), + ("enabled_at", now), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideOmittedOnBackOfficeRoute_ShouldReturnMixedSources() + { + // Arrange - one tenant has a manual override, default tenants do not + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var overrideId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", null), + ("enabled_at", now), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result.Tenants.Should().Contain(t => t.Source == "manual_override"); + } +} diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 537e147f1..667c6b554 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -956,7 +956,7 @@ public async Task GetFeatureFlagTenants_WhenStateEnabledAndTenantHasManualOverri // Arrange - tenant-1 gets a manual enable for `sso`; all other tenants remain disabled by default var flagKey = "sso"; var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, enabled: true); + InsertTenantOverride(flagKey, tenantId, true); // Act var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Enabled"); @@ -975,7 +975,7 @@ public async Task GetFeatureFlagTenants_WhenStateDisabledAndTenantIsEnabledViaOv // Arrange var flagKey = "sso"; var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, enabled: true); + InsertTenantOverride(flagKey, tenantId, true); // Act var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Disabled"); @@ -994,7 +994,7 @@ public async Task GetFeatureFlagTenants_WhenStateOmitted_ShouldNotFilterByState( // Arrange - tenant-1 gets enabled, then ask without State (omitted = no filter) var flagKey = "sso"; var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, enabled: true); + InsertTenantOverride(flagKey, tenantId, true); // Act var omittedResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); @@ -1087,7 +1087,7 @@ public async Task GetFeatureFlagUsers_WhenStateEnabledAndUserHasManualOverride_S var flagKey = "compact-view"; var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); var tenantId = DatabaseSeeder.Tenant1.Id; - InsertUserOverride(flagKey, tenantId, userId, enabled: true); + InsertUserOverride(flagKey, tenantId, userId, true); // Act var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?State=Enabled"); @@ -1107,7 +1107,7 @@ public async Task GetFeatureFlagUsers_WhenStateDisabled_ShouldReturnOnlyDisabled var flagKey = "compact-view"; var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); var tenantId = DatabaseSeeder.Tenant1.Id; - InsertUserOverride(flagKey, tenantId, userId, enabled: true); + InsertUserOverride(flagKey, tenantId, userId, true); // Act var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?State=Disabled"); @@ -1152,6 +1152,170 @@ public async Task GetFeatureFlagUsers_WhenPageOffsetExceedsTotalPages_ShouldRetu response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + // HasOverride filter tests + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideOmitted_ShouldNotFilterByOverride() + { + // Arrange - seed an override so the dataset contains both override and non-override rows (Source: "manual_override" + "default") + var flagKey = "sso"; + InsertTenantOverride(flagKey, DatabaseSeeder.Tenant1.Id, true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().Contain(t => t.Source == "manual_override"); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrue_ShouldReturnOnlyTenantsWithManualOverride() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndStateDisabled_ShouldReturnDisabledOverrides() + { + // Arrange - tenant-1 has a disabling manual override; no other tenants have manual overrides + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, false); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true&State=Disabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override" && !t.IsEnabled); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndNoTenantHasOverride_ShouldReturnEmpty() + { + // Arrange + var flagKey = "sso"; + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueOnAbRolloutFlag_ShouldExcludeAbRolloutAndDefaultRows() + { + // Arrange - beta-features is A/B-eligible. Configure a 100% rollout so every tenant evaluates as "ab_rollout", + // then add a manual override for tenant-1. HasOverride=true should keep only tenant-1. + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 99) + ] + ); + InsertTenantOverride(flagKey, tenantId, true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenHasOverrideOmitted_ShouldNotFilterByOverride() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().Contain(u => u.Source == "manual_override"); + result.Users.Should().Contain(u => u.Source == "default"); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenHasOverrideTrue_ShouldReturnOnlyUsersWithManualOverride() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().OnlyContain(u => u.Source == "manual_override"); + result.Users.Should().Contain(u => u.Id.Value == userId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenHasOverrideTrueAndRoleFiltered_ShouldReturnOnlyMatchingUsersWithOverride() + { + // Arrange - only the owner gets the override; member gets none + var flagKey = "compact-view"; + var ownerId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, ownerId, true); + + // Act + var matchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?HasOverride=true&Roles=Owner"); + var noMatchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?HasOverride=true&Roles=Member"); + + // Assert + matchResponse.ShouldBeSuccessfulGetRequest(); + noMatchResponse.ShouldBeSuccessfulGetRequest(); + var matchResult = await matchResponse.DeserializeResponse(); + var noMatchResult = await noMatchResponse.DeserializeResponse(); + matchResult.Should().NotBeNull(); + noMatchResult.Should().NotBeNull(); + matchResult.Users.Should().OnlyContain(u => u.Source == "manual_override" && u.Role == UserRole.Owner); + matchResult.Users.Should().Contain(u => u.Id.Value == ownerId); + noMatchResult.Users.Should().BeEmpty(); + } + private void InsertTenantOverride(string flagKey, TenantId tenantId, bool enabled) { var overrideId = FeatureFlagId.NewId().ToString(); From eacf9c55a0795eaf7f5dac8a3b04c5cc103bd377 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 12 May 2026 01:00:56 +0200 Subject: [PATCH 051/155] Fix post-rebase test seeds, regenerate translations, and clean up identical-branches ternary --- .../shared/translations/locale/da-DK.po | 57 ++-- .../shared/translations/locale/en-US.po | 282 ++++++++++++++++++ .../GetDashboardRevenueTrendTests.cs | 4 +- .../Domain/BillingEventRepositoryTests.cs | 4 +- .../Domain/SubscriptionRepositoryTests.cs | 4 +- .../Tests/Workers/BillingDriftWorkerTests.cs | 4 +- 6 files changed, 312 insertions(+), 43 deletions(-) diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index a545099da..24c7ae81c 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -32,11 +32,11 @@ msgstr "{0} i denne periode, eksklusiv moms" msgid "{activeUsers} active" msgstr "{activeUsers} aktive" -msgid "{count} accounts have billing drift detected." -msgstr "{count} konti har faktureringsafvigelser." +msgid "{count, plural, one {# account has billing drift detected.} other {# accounts have billing drift detected.}}" +msgstr "{count, plural, one {# konto har faktureringsafvigelser.} other {# konti har faktureringsafvigelser.}}" -msgid "{count} accounts have not been synced yet — MRR trend is incomplete." -msgstr "{count} konti er ikke synkroniseret endnu — MRR-tendensen er ufuldstændig." +msgid "{count, plural, one {# account has not been synced yet — MRR trend is incomplete.} other {# accounts have not been synced yet — MRR trend is incomplete.}}" +msgstr "{count, plural, one {# konto er endnu ikke synkroniseret — MRR-tendensen er ufuldstændig.} other {# konti er endnu ikke synkroniseret — MRR-tendensen er ufuldstændig.}}" msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" @@ -153,6 +153,9 @@ msgstr "Konti vises her, når de oprettes." msgid "Accounts will appear here as they become available." msgstr "Konti vises her, når de bliver tilgængelige." +msgid "Actions" +msgstr "Handlinger" + msgid "Active" msgstr "Aktiv" @@ -171,12 +174,6 @@ msgstr "Alle" msgid "All accounts this user is a member of, with their plan and role." msgstr "Alle konti denne bruger er medlem af, med deres abonnement og rolle." -msgid "All event types" -msgstr "Alle hændelsestyper" - -msgid "All statuses" -msgstr "Alle statusser" - msgid "All users across every account, most recently seen first. Search and filter to narrow down." msgstr "Alle brugere på tværs af alle konti, senest sete først. Søg og filtrér for at indsnævre." @@ -319,9 +316,6 @@ msgstr "Aktuelt abonnement" msgid "Custom branding" msgstr "Tilpasset branding" -msgid "Customer" -msgstr "Kunde" - msgid "Customize the login page with your organization's logo and colors" msgstr "Tilpas login-siden med din organisations logo og farver" @@ -331,8 +325,8 @@ msgstr "Mørk" msgid "Dashboard" msgstr "Dashboard" -#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, data.currency) -#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, data.currency) +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, currency) +#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, currency) msgid "Dashboard MRR mismatch: KPI shows {0}, trend latest shows {1}." msgstr "Dashboard MRR uoverensstemmelse: KPI viser {0}, tendens viser senest {1}." @@ -639,9 +633,6 @@ msgstr "Ingen aktiverede brugere" msgid "No feature flags" msgstr "Ingen feature flags" -msgid "No invoices match your filters" -msgstr "Ingen fakturaer matcher dine filtre" - msgid "No invoices yet" msgstr "Ingen fakturaer endnu" @@ -920,12 +911,12 @@ msgstr "Afstem med Stripe?" msgid "Reconciling..." msgstr "Afstemmer..." -msgid "Reduce spacing between UI elements for a denser layout" -msgstr "Reducér afstanden mellem UI-elementer for et tættere layout" - msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Posteringer vises her, når konti abonnerer, og Stripe-webhooks behandles." +msgid "Reduce spacing between UI elements for a denser layout" +msgstr "Reducér afstanden mellem UI-elementer for et tættere layout" + msgid "Refunded" msgstr "Refunderet" @@ -1001,12 +992,6 @@ msgstr "Udrulningsprocent opdateret" msgid "Run disaster recovery" msgstr "Kør katastrofegendannelse" -msgid "Save" -msgstr "Gem" - -msgid "Saving..." -msgstr "Gemmer..." - msgid "Scheduled plan change" msgstr "Planlagt planændring" @@ -1094,9 +1079,6 @@ msgstr "Tilmeldt" msgid "Subscribed since" msgstr "Abonneret siden" -msgid "Subscription canceled" -msgstr "Abonnement opsagt" - msgid "Subscription state" msgstr "Abonnementstilstand" @@ -1112,6 +1094,9 @@ msgstr "Abonnementer" msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." +msgid "Subtotal" +msgstr "Subtotal" + msgid "Succeeded" msgstr "Gennemført" @@ -1142,15 +1127,12 @@ msgstr "Tema" msgid "This account has no users." msgstr "Denne konto har ingen brugere." -msgid "this period" -msgstr "denne periode" - -msgid "This account previously had a paid subscription that ended." -msgstr "Denne konto havde tidligere et betalt abonnement, der sluttede." - msgid "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." msgstr "Dette flag styres af abonnementet. Det aktiveres automatisk for konti på det krævede abonnement eller højere." +msgid "this period" +msgstr "denne periode" + msgid "This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort recovery that may produce incorrect subscription state or billing event rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." msgstr "Dette genopbygger faktureringshændelsesloggen fra denne kontos arkiverede Stripe-payloads. Det er en bedst muligt-gendannelse, der kan producere forkerte abonnementstilstande eller faktureringshændelser. Kør kun denne handling, når standard Afstem med Stripe er forsøgt og ikke ryddede afvigelserne." @@ -1194,9 +1176,6 @@ msgstr "Prøv at rydde søgningen for at se flere resultater." msgid "Try out experimental user interface components" msgstr "Afprøv eksperimentelle brugergrænsefladekomponenter" -msgid "Type" -msgstr "Type" - msgid "Unclassified" msgstr "Uklassificeret" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 190dc5b5d..6c564ed40 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -41,6 +41,22 @@ msgstr "{count, plural, one {# account has not been synced yet — MRR trend is msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# day ago} other {# days ago}}" +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email +msgid "{featureFlagDescription} disabled for {0}" +msgstr "{featureFlagDescription} disabled for {0}" + +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email +msgid "{featureFlagDescription} enabled for {0}" +msgstr "{featureFlagDescription} enabled for {0}" + +msgid "{flagName} disabled" +msgstr "{flagName} disabled" + +msgid "{flagName} enabled" +msgstr "{flagName} enabled" + msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inactive" @@ -87,12 +103,18 @@ msgstr "7d" msgid "90d" msgstr "90d" +msgid "A/B rollout" +msgstr "A/B rollout" + msgid "Account" msgstr "Account" msgid "Account actions" msgstr "Account actions" +msgid "Account flags" +msgstr "Account flags" + msgid "Account growth" msgstr "Account growth" @@ -101,9 +123,15 @@ msgstr "Account growth" msgid "Account has {0} drift discrepancies. Last reconciled at {1}. If standard reconcile cannot clear the drift, disaster recovery from archived Stripe events is available as a last resort." msgstr "Account has {0} drift discrepancies. Last reconciled at {1}. If standard reconcile cannot clear the drift, disaster recovery from archived Stripe events is available as a last resort." +msgid "Account ID" +msgstr "Account ID" + msgid "Account preview" msgstr "Account preview" +msgid "Account status" +msgstr "Account status" + msgid "Account users" msgstr "Account users" @@ -113,9 +141,18 @@ msgstr "accounts" msgid "Accounts" msgstr "Accounts" +msgid "Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are available for plan-managed flags." +msgstr "Accounts are automatically enabled or disabled based on their subscription plan. No manual overrides are available for plan-managed flags." + +msgid "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." +msgstr "Accounts are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific accounts." + msgid "Accounts will appear here as they are created." msgstr "Accounts will appear here as they are created." +msgid "Accounts will appear here as they become available." +msgstr "Accounts will appear here as they become available." + msgid "Actions" msgstr "Actions" @@ -146,6 +183,9 @@ msgstr "All-time" msgid "All-time, excluding VAT" msgstr "All-time, excluding VAT" +msgid "Allow users to authenticate using enterprise identity providers" +msgstr "Allow users to authenticate using enterprise identity providers" + msgid "Amount" msgstr "Amount" @@ -169,9 +209,15 @@ msgstr "Back Office - Localhost" msgid "Back Office overview · {today}" msgstr "Back Office overview · {today}" +msgid "Back to feature flags" +msgstr "Back to feature flags" + msgid "Basis" msgstr "Basis" +msgid "Beta features" +msgstr "Beta features" + msgid "Billing" msgstr "Billing" @@ -196,6 +242,9 @@ msgstr "Blended MRR" msgid "Browser" msgstr "Browser" +msgid "Bucket" +msgstr "Bucket" + msgid "Cancel" msgstr "Cancel" @@ -238,6 +287,9 @@ msgstr "Close" msgid "Close account preview" msgstr "Close account preview" +msgid "Compact view" +msgstr "Compact view" + msgid "Contact your administrator." msgstr "Contact your administrator." @@ -261,6 +313,12 @@ msgstr "Current period" msgid "Current plan" msgstr "Current plan" +msgid "Custom branding" +msgstr "Custom branding" + +msgid "Customize the login page with your organization's logo and colors" +msgstr "Customize the login page with your organization's logo and colors" + msgid "Dark" msgstr "Dark" @@ -284,6 +342,9 @@ msgstr "Desktop" msgid "Device" msgstr "Device" +msgid "Disabled" +msgstr "Disabled" + msgid "Disaster recovery complete" msgstr "Disaster recovery complete" @@ -305,6 +366,12 @@ msgstr "Downgrading" msgid "Drift detected" msgstr "Drift detected" +msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." +msgstr "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." + +msgid "Early access to experimental features before general availability" +msgstr "Early access to experimental features before general availability" + msgid "Email" msgstr "Email" @@ -314,6 +381,18 @@ msgstr "Email confirmed" msgid "Email pending" msgstr "Email pending" +msgid "Enabled" +msgstr "Enabled" + +#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) +#. placeholder {1}: formatTimestamp(featureFlag.disabledAt) +msgid "Enabled period: {0} - {1}" +msgstr "Enabled period: {0} - {1}" + +#. placeholder {0}: formatTimestamp(featureFlag.enabledAt) +msgid "Enabled: {0}" +msgstr "Enabled: {0}" + msgid "Enter kiosk mode" msgstr "Enter kiosk mode" @@ -335,6 +414,9 @@ msgstr "Every sign-in attempt over the last 30 days, successful or failed, acros msgid "Exit kiosk mode" msgstr "Exit kiosk mode" +msgid "Experimental UI" +msgstr "Experimental UI" + msgid "Expired" msgstr "Expired" @@ -344,15 +426,39 @@ msgstr "Expires" msgid "Failed" msgstr "Failed" +msgid "Feature flag activated" +msgstr "Feature flag activated" + +msgid "Feature flag deactivated" +msgstr "Feature flag deactivated" + +msgid "Feature flags" +msgstr "Feature flags" + +msgid "Feature flags are defined in code. This view controls their activation and rollout." +msgstr "Feature flags are defined in code. This view controls their activation and rollout." + msgid "Free" msgstr "Free" +msgid "Gated by subscription plan and recomputed when the plan changes. Configured only in code." +msgstr "Gated by subscription plan and recomputed when the plan changes. Configured only in code." + +msgid "Gated by the account's subscription plan. Read-only." +msgstr "Gated by the account's subscription plan. Read-only." + msgid "Go to home" msgstr "Go to home" msgid "Google" msgstr "Google" +msgid "Google OAuth" +msgstr "Google OAuth" + +msgid "Has override" +msgstr "Has override" + msgid "Hide details" msgstr "Hide details" @@ -437,6 +543,9 @@ msgstr "Logo" msgid "Main navigation" msgstr "Main navigation" +msgid "Manual override" +msgstr "Manual override" + msgid "Member" msgstr "Member" @@ -464,6 +573,9 @@ msgstr "MRR trend" msgid "Name" msgstr "Name" +msgid "Name:" +msgstr "Name:" + msgid "Navigation" msgstr "Navigation" @@ -479,6 +591,9 @@ msgstr "Next" msgid "No account memberships" msgstr "No account memberships" +msgid "No accounts match these filters" +msgstr "No accounts match these filters" + msgid "No accounts match your filters" msgstr "No accounts match your filters" @@ -503,6 +618,21 @@ msgstr "No billing events yet" msgid "No change" msgstr "No change" +msgid "No disabled accounts" +msgstr "No disabled accounts" + +msgid "No disabled users" +msgstr "No disabled users" + +msgid "No enabled accounts" +msgstr "No enabled accounts" + +msgid "No enabled users" +msgstr "No enabled users" + +msgid "No feature flags" +msgstr "No feature flags" + msgid "No invoices yet" msgstr "No invoices yet" @@ -560,12 +690,21 @@ msgstr "No sessions" msgid "No sign-in attempts in the last 30 days." msgstr "No sign-in attempts in the last 30 days." +msgid "No tenant-scoped feature flags are defined for this account." +msgstr "No tenant-scoped feature flags are defined for this account." + msgid "No transactions" msgstr "No transactions" +msgid "No user-scoped feature flags are defined." +msgstr "No user-scoped feature flags are defined." + msgid "No users" msgstr "No users" +msgid "No users match these filters" +msgstr "No users match these filters" + msgid "No users match your filters." msgstr "No users match your filters." @@ -590,6 +729,10 @@ msgstr "One-time password" msgid "Open account" msgstr "Open account" +#. placeholder {0}: tenant.name +msgid "Open account {0}" +msgstr "Open account {0}" + msgid "Open credit note" msgstr "Open credit note" @@ -599,6 +742,9 @@ msgstr "Open in Stripe" msgid "Open invoice" msgstr "Open invoice" +msgid "Open user {displayName}" +msgstr "Open user {displayName}" + msgid "Other" msgstr "Other" @@ -608,6 +754,25 @@ msgstr "Outcome" msgid "over period" msgstr "over period" +msgid "Override" +msgstr "Override" + +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email +msgid "Override for {0}" +msgstr "Override for {0}" + +msgid "Override for {flagName}" +msgstr "Override for {flagName}" + +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email +msgid "Override removed for {0}" +msgstr "Override removed for {0}" + +msgid "Override removed for {flagName}" +msgstr "Override removed for {flagName}" + msgid "Overview" msgstr "Overview" @@ -647,6 +812,18 @@ msgstr "Pending" msgid "per month, billed monthly" msgstr "per month, billed monthly" +msgid "Per-account flags. Toggle the override switch to enable or disable for this account." +msgstr "Per-account flags. Toggle the override switch to enable or disable for this account." + +msgid "Per-tenant flags. Owners can toggle configurable flags. Admins control A/B rollouts." +msgstr "Per-tenant flags. Owners can toggle configurable flags. Admins control A/B rollouts." + +msgid "Per-user flags. Toggle the override switch to enable or disable for this user." +msgstr "Per-user flags. Toggle the override switch to enable or disable for this user." + +msgid "Per-user flags. Users can toggle configurable flags. Admins control A/B rollouts." +msgstr "Per-user flags. Users can toggle configurable flags. Admins control A/B rollouts." + msgid "Period" msgstr "Period" @@ -662,9 +839,18 @@ msgstr "Plan changes, renewals, cancellations, and payment outcomes — the subs msgid "Plan distribution" msgstr "Plan distribution" +msgid "Plan flags" +msgstr "Plan flags" + msgid "Plan transition" msgstr "Plan transition" +msgid "Platform" +msgstr "Platform" + +msgid "Platform-wide capabilities set at deployment via environment variables. Configured only in code." +msgstr "Platform-wide capabilities set at deployment via environment variables. Configured only in code." + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -728,6 +914,9 @@ msgstr "Reconciling..." msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Records will appear here as accounts subscribe and Stripe webhooks are processed." +msgid "Reduce spacing between UI elements for a denser layout" +msgstr "Reduce spacing between UI elements for a denser layout" + msgid "Refunded" msgstr "Refunded" @@ -737,6 +926,17 @@ msgstr "Refunds and credit notes" msgid "Refunds and credit notes across all accounts." msgstr "Refunds and credit notes across all accounts." +msgid "Remove override" +msgstr "Remove override" + +#. placeholder {0}: tenant.name +#. placeholder {0}: user.email +msgid "Remove override for {0}" +msgstr "Remove override for {0}" + +msgid "Remove override for {flagName}" +msgstr "Remove override for {flagName}" + msgid "Renewal" msgstr "Renewal" @@ -756,6 +956,12 @@ msgstr "Renews {0}" msgid "Replayed {0} archived events into the billing event ledger at {1}." msgstr "Replayed {0} archived events into the billing event ledger at {1}." +msgid "Required plan" +msgstr "Required plan" + +msgid "Required plan:" +msgstr "Required plan:" + msgid "Revenue" msgstr "Revenue" @@ -765,6 +971,24 @@ msgstr "Revoked" msgid "Role" msgstr "Role" +msgid "Rollout" +msgstr "Rollout" + +msgid "Rollout %" +msgstr "Rollout %" + +msgid "Rollout bucket information" +msgstr "Rollout bucket information" + +msgid "Rollout buckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgstr "Rollout buckets: {rolloutBucketStart}-{rolloutBucketEnd} ({rolloutPercentage}%)" + +msgid "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" +msgstr "Rollout buckets: {rolloutBucketStart}-99 and 0-{rolloutBucketEnd} ({rolloutPercentage}%)" + +msgid "Rollout percentage updated" +msgstr "Rollout percentage updated" + msgid "Run disaster recovery" msgstr "Run disaster recovery" @@ -780,6 +1004,12 @@ msgstr "Search" msgid "Search by account name" msgstr "Search by account name" +msgid "Search by account name or ID" +msgstr "Search by account name or ID" + +msgid "Search by account name or owner email" +msgstr "Search by account name or owner email" + msgid "Search by email, name, or account" msgstr "Search by email, name, or account" @@ -789,6 +1019,9 @@ msgstr "Search by name" msgid "Search by name or email" msgstr "Search by name or email" +msgid "Search results" +msgstr "Search results" + msgid "Search users" msgstr "Search users" @@ -801,6 +1034,9 @@ msgstr "Sessions" msgid "Show details" msgstr "Show details" +msgid "Sign in with Google using OpenID Connect" +msgstr "Sign in with Google using OpenID Connect" + msgid "Signed up" msgstr "Signed up" @@ -813,18 +1049,30 @@ msgstr "Signed up <0>{0}<1>{1}" msgid "Since {0}" msgstr "Since {0}" +msgid "Single sign-on" +msgstr "Single sign-on" + msgid "Small" msgstr "Small" msgid "Something went wrong" msgstr "Something went wrong" +msgid "Source" +msgstr "Source" + msgid "Standard" msgstr "Standard" +msgid "State" +msgstr "State" + msgid "Status" msgstr "Status" +msgid "Stripe-powered subscription billing and plan management" +msgstr "Stripe-powered subscription billing and plan management" + msgid "Subscribed" msgstr "Subscribed" @@ -840,6 +1088,9 @@ msgstr "Subscription, payment, and billing transitions will appear here as Strip msgid "Subscription, payment, and billing transitions will appear here." msgstr "Subscription, payment, and billing transitions will appear here." +msgid "Subscriptions" +msgstr "Subscriptions" + msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Subscriptions, upgrades, and cancellations will appear here." @@ -861,6 +1112,9 @@ msgstr "Suspended" msgid "System" msgstr "System" +msgid "System flags" +msgstr "System flags" + msgid "Tablet" msgstr "Tablet" @@ -873,6 +1127,9 @@ msgstr "Theme" msgid "This account has no users." msgstr "This account has no users." +msgid "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." +msgstr "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." + msgid "this period" msgstr "this period" @@ -885,6 +1142,16 @@ msgstr "This user has no recorded sessions." msgid "This user is not a member of any account." msgstr "This user is not a member of any account." +#. placeholder {0}: getFeatureFlagName(featureFlag.key) +msgid "Toggle {0}" +msgstr "Toggle {0}" + +msgid "Toggle the override switch to enable this feature for specific accounts." +msgstr "Toggle the override switch to enable this feature for specific accounts." + +msgid "Toggle the override switch to enable this feature for specific users." +msgstr "Toggle the override switch to enable this feature for specific users." + msgid "Total" msgstr "Total" @@ -906,6 +1173,9 @@ msgstr "Try clearing the search or filters to see more results." msgid "Try clearing the search to see more results." msgstr "Try clearing the search to see more results." +msgid "Try out experimental user interface components" +msgstr "Try out experimental user interface components" + msgid "Unclassified" msgstr "Unclassified" @@ -921,21 +1191,33 @@ msgstr "User" msgid "User detail" msgstr "User detail" +msgid "User flags" +msgstr "User flags" + msgid "User logins / day" msgstr "User logins / day" msgid "User menu" msgstr "User menu" +msgid "User status" +msgstr "User status" + msgid "Users" msgstr "Users" msgid "Users active" msgstr "Users active" +msgid "Users are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific users." +msgstr "Users are automatically included based on their rollout bucket. Use overrides to manually include or exclude specific users." + msgid "Users will appear here as accounts are created." msgstr "Users will appear here as accounts are created." +msgid "Users will appear here as they become available." +msgstr "Users will appear here as they become available." + msgid "VAT" msgstr "VAT" diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs index b48f61600..833639cbb 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs @@ -303,7 +303,9 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 0), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs b/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs index 535d5b5cb..e632d38ed 100644 --- a/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs +++ b/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs @@ -54,7 +54,9 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 0), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs index 192c9878a..4ce36568d 100644 --- a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs +++ b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs @@ -54,7 +54,9 @@ private TenantId SeedTenant(string name) ("name", name), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 0), + ("feature_flag_version", 0) ] ); return tenantId; diff --git a/application/account/Tests/Workers/BillingDriftWorkerTests.cs b/application/account/Tests/Workers/BillingDriftWorkerTests.cs index d94b6d2fd..bd704f37e 100644 --- a/application/account/Tests/Workers/BillingDriftWorkerTests.cs +++ b/application/account/Tests/Workers/BillingDriftWorkerTests.cs @@ -239,7 +239,9 @@ private long SeedExtraTenantWithSubscription(string stripeCustomerId, string str ("name", $"Worker Test Tenant {tenantId.Value}"), ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), - ("logo", """{"Url":null,"Version":0}""") + ("logo", """{"Url":null,"Version":0}"""), + ("rollout_bucket", 0), + ("feature_flag_version", 0) ] ); From 6af6d0d39fad884e2c91fdf0d96f37728982f8fc Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 12 May 2026 23:25:18 +0200 Subject: [PATCH 052/155] Update feature flag e2e test to match 3-chip single-select state filter --- .../tests/e2e/feature-flag-flows.spec.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index c9ce65f15..7534f7153 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -206,46 +206,61 @@ test.describe("@comprehensive", () => { expect(response.ok()).toBe(true); })(); - // === TENANTS: STATE CHIPS (multi-select; default Enabled pressed; All sentinel renders both pressed) === + // === TENANTS: STATE CHIPS (single-select; default Enabled pressed; All/Enabled/Disabled exclusive) === const accountsTable = page.getByRole("table", { name: "Accounts" }); const stateGroup = page.getByRole("group", { name: "State" }); + const allChip = stateGroup.getByRole("button", { name: "All" }); const enabledChip = stateGroup.getByRole("button", { name: "Enabled" }); const disabledChip = stateGroup.getByRole("button", { name: "Disabled" }); - await step("Reload bare URL & verify Enabled chip is pressed by default and Disabled chip is unpressed")( + await step("Reload bare URL & verify Enabled chip is pressed by default and the other chips are unpressed")( async () => { await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); await expect(enabledChip).toHaveAttribute("aria-pressed", "true"); await expect(disabledChip).toHaveAttribute("aria-pressed", "false"); + await expect(allChip).toHaveAttribute("aria-pressed", "false"); await expect(accountsTable).toBeVisible(); } )(); - await step("Click Disabled chip from default & verify URL switches to the All state and the table shows all rows")( + await step("Click Disabled chip from default & verify URL switches to Disabled and only Disabled is pressed")( async () => { await disabledChip.click(); - await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=All`); + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=Disabled`); + await expect(disabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(enabledChip).toHaveAttribute("aria-pressed", "false"); await expect(accountsTable).toBeVisible(); } )(); - await step("Reload from the All URL & verify both chips appear pressed")(async () => { + await step("Click All chip from Disabled & verify URL switches to All and only All is pressed")(async () => { + await allChip.click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=All`); + await expect(allChip).toHaveAttribute("aria-pressed", "true"); + await expect(enabledChip).toHaveAttribute("aria-pressed", "false"); + await expect(disabledChip).toHaveAttribute("aria-pressed", "false"); + })(); + + await step("Reload from the All URL & verify only the All chip is pressed")(async () => { await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=All`); - await expect(enabledChip).toHaveAttribute("aria-pressed", "true"); - await expect(disabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(allChip).toHaveAttribute("aria-pressed", "true"); + await expect(enabledChip).toHaveAttribute("aria-pressed", "false"); + await expect(disabledChip).toHaveAttribute("aria-pressed", "false"); })(); - await step("Click Enabled chip from the All state & verify only Disabled remains pressed and URL is Disabled")( + await step("Click Enabled chip from the All state & verify URL drops the state param and only Enabled is pressed")( async () => { await enabledChip.click(); - await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=Disabled`); - await expect(disabledChip).toHaveAttribute("aria-pressed", "true"); - await expect(enabledChip).toHaveAttribute("aria-pressed", "false"); + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features`); + await expect(enabledChip).toHaveAttribute("aria-pressed", "true"); + await expect(allChip).toHaveAttribute("aria-pressed", "false"); + await expect(disabledChip).toHaveAttribute("aria-pressed", "false"); } )(); From 5ad57756222b4859eba234b7d42475e9221edd3d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 16:25:45 +0200 Subject: [PATCH 053/155] Add feature-flag definition reconciler that converges DB to C# definitions on Worker startup --- .../20260310000100_SeedFeatureFlags.cs | 55 ----- ...0260404100100_SeedPlanBasedFeatureFlags.cs | 81 -------- ...60513132500_AddOrphanedAtToFeatureFlags.cs | 14 ++ .../RemoveTenantFeatureFlagOverride.cs | 3 +- .../Commands/SetTenantFeatureFlagInternal.cs | 3 +- .../FeatureFlags/Domain/FeatureFlag.cs | 18 ++ .../Domain/FeatureFlagRepository.cs | 12 ++ .../GetTenantConfigurableFeatureFlags.cs | 7 +- .../Queries/GetTenantFeatureFlags.cs | 9 +- .../GetUserConfigurableFeatureFlags.cs | 7 +- .../Queries/GetUserFeatureFlags.cs | 9 +- .../Shared/FeatureFlagEvaluator.cs | 11 +- .../Shared/PlanBasedFeatureFlagEvaluator.cs | 9 +- .../Tests/FeatureFlags/FeatureFlagTests.cs | 12 +- .../FeatureFlagDefinitionReconcilerTests.cs | 190 ++++++++++++++++++ .../FeatureFlagDefinitionReconciler.cs | 110 ++++++++++ application/account/Workers/Program.cs | 7 + .../FeatureFlags/FeatureFlagDefinition.cs | 3 +- .../SharedKernel/FeatureFlags/FeatureFlags.cs | 20 +- ...ApplicationInsightsTelemetryInitializer.cs | 4 + .../Telemetry/OpenTelemetryEnricher.cs | 4 + 21 files changed, 397 insertions(+), 191 deletions(-) delete mode 100644 application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs delete mode 100644 application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs create mode 100644 application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs create mode 100644 application/account/Tests/Workers/FeatureFlagDefinitionReconcilerTests.cs create mode 100644 application/account/Workers/FeatureFlagDefinitionReconciler.cs diff --git a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs deleted file mode 100644 index 2c7f313d0..000000000 --- a/application/account/Core/Database/DataMigrations/20260310000100_SeedFeatureFlags.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Account.Features.FeatureFlags.Domain; -using Microsoft.EntityFrameworkCore; -using Npgsql; -using SharedKernel.Database; -using SharedKernel.FeatureFlags; - -namespace Account.Database.DataMigrations; - -public sealed class SeedFeatureFlags(AccountDbContext dbContext) : IDataMigration -{ - public string Id => "20260310000100_SeedFeatureFlags"; - - public TimeSpan Timeout { get; } = TimeSpan.FromMinutes(1); - - public async Task ExecuteAsync(CancellationToken cancellationToken) - { - var featureFlags = FeatureFlags.GetAll(); - var now = DateTimeOffset.UtcNow; - - var seededCount = 0; - foreach (var featureFlag in featureFlags) - { - if (featureFlag.Scope == FeatureFlagScope.System) continue; - - seededCount++; - var id = FeatureFlagId.NewId().Value; - - var source = featureFlag.RequiredPlan is not null ? "Plan" : "Manual"; - - await dbContext.Database.ExecuteSqlRawAsync( - """ - INSERT INTO feature_flags (id, flag_key, tenant_id, user_id, created_at, modified_at, enabled_at, disabled_at, bucket_start, bucket_end, configurable_by_tenant, configurable_by_user, source) - VALUES (@id, @flagKey, NULL, NULL, @now, NULL, NULL, NULL, NULL, NULL, @configurableByTenant, @configurableByUser, @source) - ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET - configurable_by_tenant = @configurableByTenant, - configurable_by_user = @configurableByUser, - source = @source - """, - [ - new NpgsqlParameter("@id", id), - new NpgsqlParameter("@flagKey", featureFlag.Key), - new NpgsqlParameter("@now", now), - new NpgsqlParameter("@configurableByTenant", featureFlag.ConfigurableByTenant), - new NpgsqlParameter("@configurableByUser", featureFlag.ConfigurableByUser), - new NpgsqlParameter("@source", source) - ], - cancellationToken - ); - } - - await dbContext.SaveChangesAsync(cancellationToken); - - return $"Upserted {seededCount} feature featureFlag base rows"; - } -} diff --git a/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs b/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs deleted file mode 100644 index b35645920..000000000 --- a/application/account/Core/Database/DataMigrations/20260404100100_SeedPlanBasedFeatureFlags.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Account.Features.FeatureFlags.Domain; -using Account.Features.Subscriptions.Domain; -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using Npgsql; -using SharedKernel.Database; -using SharedKernel.FeatureFlags; - -namespace Account.Database.DataMigrations; - -public sealed class SeedPlanBasedFeatureFlags(AccountDbContext dbContext) : IDataMigration -{ - public string Id => "20260404100100_SeedPlanBasedFeatureFlags"; - - public TimeSpan Timeout { get; } = TimeSpan.FromMinutes(5); - - public async Task ExecuteAsync(CancellationToken cancellationToken) - { - var planFeatureFlags = FeatureFlags.GetAll().Where(f => f.RequiredPlan is not null).ToArray(); - if (planFeatureFlags.Length == 0) return "No plan-based feature flags defined"; - - var planMapping = new Dictionary - { - [nameof(SubscriptionPlan.Basis)] = PlanTier.Free, - [nameof(SubscriptionPlan.Standard)] = PlanTier.Standard, - [nameof(SubscriptionPlan.Premium)] = PlanTier.Premium - }; - - var tenants = await dbContext.Database.SqlQueryRaw( - """ - SELECT t.id AS tenant_id, s.plan AS plan - FROM tenants t - JOIN subscriptions s ON s.tenant_id = t.id - WHERE t.deleted_at IS NULL - """ - ).ToArrayAsync(cancellationToken); - - var now = DateTimeOffset.UtcNow; - var seededCount = 0; - - foreach (var tenant in tenants) - { - if (!planMapping.TryGetValue(tenant.Plan, out var tenantSubscriptionPlan)) continue; - - foreach (var featureFlag in planFeatureFlags) - { - var shouldBeEnabled = tenantSubscriptionPlan >= featureFlag.RequiredPlan!.Value; - var id = FeatureFlagId.NewId().Value; - - await dbContext.Database.ExecuteSqlRawAsync( - """ - INSERT INTO feature_flags (id, flag_key, tenant_id, user_id, created_at, modified_at, enabled_at, disabled_at, bucket_start, bucket_end, configurable_by_tenant, configurable_by_user, source) - VALUES (@id, @flagKey, @tenantId, NULL, @now, NULL, @enabledAt, @disabledAt, NULL, NULL, false, false, 'Plan') - ON CONFLICT (flag_key, tenant_id, user_id) DO UPDATE SET - enabled_at = CASE WHEN feature_flags.source = 'Plan' THEN @enabledAt ELSE feature_flags.enabled_at END, - disabled_at = CASE WHEN feature_flags.source = 'Plan' THEN @disabledAt ELSE feature_flags.disabled_at END, - source = CASE WHEN feature_flags.source = 'Manual' THEN 'Plan' ELSE feature_flags.source END - """, - [ - new NpgsqlParameter("@id", id), - new NpgsqlParameter("@flagKey", featureFlag.Key), - new NpgsqlParameter("@tenantId", tenant.TenantId), - new NpgsqlParameter("@now", now), - new NpgsqlParameter("@enabledAt", shouldBeEnabled ? now : DBNull.Value), - new NpgsqlParameter("@disabledAt", shouldBeEnabled ? DBNull.Value : now) - ], - cancellationToken - ); - - seededCount++; - } - } - - await dbContext.SaveChangesAsync(cancellationToken); - - return $"Seeded {seededCount} plan-based feature featureFlag overrides across {tenants.Length} tenants"; - } - - [UsedImplicitly] - private sealed record TenantSubscriptionInfo(long TenantId, string Plan); -} diff --git a/application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs b/application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs new file mode 100644 index 000000000..c5f06d650 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260513132500_AddOrphanedAtToFeatureFlags")] +public sealed class AddOrphanedAtToFeatureFlags : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("orphaned_at", "feature_flags", "timestamptz", nullable: true); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs index d235c1af1..e516cfd93 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -25,7 +25,8 @@ public RemoveTenantFeatureFlagOverrideValidator() RuleFor(x => x.FlagKey) .NotEmpty().WithMessage("Feature flag key must not be empty.") .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.RequiredPlan is null).WithMessage("Plan-gated feature flags cannot be removed manually; change the tenant's subscription plan instead."); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index f4dd0c2ab..57b0f21d5 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -27,7 +27,8 @@ public SetTenantFeatureFlagInternalValidator() RuleFor(x => x.FlagKey) .NotEmpty().WithMessage("Feature flag key must not be empty.") .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key) is not null).WithMessage("Feature flag key must exist in the registry.") - .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope."); + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.Scope == FeatureFlagScope.Tenant).WithMessage("Feature flag must have tenant scope.") + .Must(key => SharedKernel.FeatureFlags.FeatureFlags.Get(key)?.RequiredPlan is null).WithMessage("Plan-gated feature flags cannot be set manually; change the tenant's subscription plan instead."); } } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index 28782696c..796cb26cb 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using SharedKernel.Domain; using SharedKernel.StronglyTypedIds; @@ -43,6 +44,11 @@ private FeatureFlag(string flagKey, long? tenantId, string? userId, FeatureFlagS public FeatureFlagSource Source { get; private set; } + public DateTimeOffset? OrphanedAt { get; private set; } + + [NotMapped] + public bool IsActive => EnabledAt is not null && (DisabledAt is null || EnabledAt > DisabledAt); + public static FeatureFlag Create(string flagKey, FeatureFlagSource source = FeatureFlagSource.Manual) { return new FeatureFlag(flagKey, null, null, source); @@ -71,6 +77,18 @@ public void Deactivate(DateTimeOffset now) DisabledAt = now; } + public void SetSource(FeatureFlagSource source) + { + Source = source; + } + + public void MarkOrphaned(DateTimeOffset now) + { + if (OrphanedAt is not null) return; + + OrphanedAt = now; + } + public void SetRolloutRange(int? rolloutBucketStart, int? rolloutBucketEnd) { if (rolloutBucketStart is null != rolloutBucketEnd is null) diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index 543bb4abb..cbeae9b7b 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -22,6 +22,13 @@ public interface IFeatureFlagRepository : ICrudRepository GetUserOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); Task GetPlanBasedOverridesForTenantAsync(long tenantId, CancellationToken cancellationToken); + + /// + /// Returns every feature_flag row across all tenants and users. Used by the reconciler to sweep for + /// orphans (rows whose flag_key no longer exists in FeatureFlags.cs). MUST only be called from + /// startup/admin paths — never from a tenant-scoped request. + /// + Task GetAllRowsUnfilteredAsync(CancellationToken cancellationToken); } internal sealed class FeatureFlagRepository(AccountDbContext accountDbContext) @@ -81,4 +88,9 @@ public async Task GetPlanBasedOverridesForTenantAsync(long tenant .Where(f => f.TenantId == tenantId && f.UserId == null && f.Source == FeatureFlagSource.Plan) .ToArrayAsync(cancellationToken); } + + public async Task GetAllRowsUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.ToArrayAsync(cancellationToken); + } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs index efd637ff0..27122a471 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs @@ -33,16 +33,11 @@ public async Task> Handle(GetTena .Select(definition => { tenantOverrides.TryGetValue(definition.Key, out var tenantOverride); - var enabled = tenantOverride is not null && IsActive(tenantOverride); + var enabled = tenantOverride?.IsActive == true; return new TenantConfigurableFeatureFlag(definition.Key, enabled); } ).ToArray(); return new TenantConfigurableFeatureFlagsResponse(flags); } - - private static bool IsActive(FeatureFlag row) - { - return row.EnabledAt is not null && (row.DisabledAt is null || row.EnabledAt > row.DisabledAt); - } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs index a45e120b7..cb7ff70d2 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs @@ -64,7 +64,7 @@ int tenantRolloutBucket ) { baseRowsByKey.TryGetValue(definition.Key, out var baseRow); - var isBaseRowActive = baseRow is not null && IsActive(baseRow); + var isBaseRowActive = baseRow?.IsActive == true; tenantOverridesByKey.TryGetValue(definition.Key, out var tenantOverride); bool isEnabled; @@ -72,7 +72,7 @@ int tenantRolloutBucket if (tenantOverride is not null) { - isEnabled = IsActive(tenantOverride); + isEnabled = tenantOverride.IsActive; // The row's Source column is authoritative — a manually-toggled plan-gated flag must still surface as // "manual_override" so admins see they overrode the plan-driven default, rather than the plan granting it. source = tenantOverride.Source == FeatureFlagSource.Plan ? "plan" : "manual_override"; @@ -105,11 +105,6 @@ int tenantRolloutBucket ); } - private static bool IsActive(FeatureFlag featureFlag) - { - return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); - } - private static int? ComputeRolloutPercentage(int? bucketStart, int? bucketEnd) { if (bucketStart is null || bucketEnd is null) return null; diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs index 297a495df..c3726a091 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs @@ -34,16 +34,11 @@ public async Task> Handle(GetUserCo .Select(definition => { userOverrides.TryGetValue(definition.Key, out var userOverride); - var enabled = userOverride is not null && IsActive(userOverride); + var enabled = userOverride?.IsActive == true; return new UserConfigurableFeatureFlag(definition.Key, enabled); } ).ToArray(); return new UserConfigurableFeatureFlagsResponse(flags); } - - private static bool IsActive(FeatureFlag row) - { - return row.EnabledAt is not null && (row.DisabledAt is null || row.EnabledAt > row.DisabledAt); - } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs index 6f3251afd..237fa9eb9 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs @@ -65,7 +65,7 @@ TenantId tenantId ) { baseRowsByKey.TryGetValue(definition.Key, out var baseRow); - var isBaseRowActive = baseRow is not null && IsActive(baseRow); + var isBaseRowActive = baseRow?.IsActive == true; userOverridesByKey.TryGetValue(definition.Key, out var userOverride); bool isEnabled; @@ -73,7 +73,7 @@ TenantId tenantId if (userOverride is not null) { - isEnabled = IsActive(userOverride); + isEnabled = userOverride.IsActive; source = "manual_override"; } else if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) @@ -104,11 +104,6 @@ TenantId tenantId ); } - private static bool IsActive(FeatureFlag featureFlag) - { - return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); - } - private static int? ComputeRolloutPercentage(int? bucketStart, int? bucketEnd) { if (bucketStart is null || bucketEnd is null) return null; diff --git a/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs b/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs index 98e0b1f2c..0d9bf2419 100644 --- a/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs @@ -24,7 +24,7 @@ public async Task> EvaluateAsync(long tenantId, string use var baseRow = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId is null && f.UserId is null); if (baseRow is null) continue; - if (!IsActive(baseRow)) continue; + if (!baseRow.IsActive) continue; if (definition.ParentDependency is not null && !enabledFeatureFlagSet.Contains(definition.ParentDependency)) continue; @@ -49,7 +49,7 @@ private static bool EvaluateTenantScope(FeatureFlagDefinition definition, Featur var tenantOverride = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId == tenantId && f.UserId is null); if (tenantOverride is not null) { - return IsActive(tenantOverride); + return tenantOverride.IsActive; } if (definition.IsAbTestEligible && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) @@ -67,7 +67,7 @@ private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureF var userOverride = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId == tenantId && f.UserId == userId); if (userOverride is not null) { - return IsActive(userOverride); + return userOverride.IsActive; } if (definition.IsAbTestEligible && userRolloutBucket is not null && baseRow.BucketStart is not null && baseRow.BucketEnd is not null) @@ -78,11 +78,6 @@ private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureF return false; } - private static bool IsActive(FeatureFlag featureFlag) - { - return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); - } - private static FeatureFlagDefinition[] TopologicalSort(FeatureFlagDefinition[] definitions) { var result = new List(definitions.Length); diff --git a/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs index eb7198f82..14fd30fba 100644 --- a/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs @@ -34,7 +34,7 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio await featureFlagRepository.AddAsync(featureFlag, cancellationToken); changed = true; } - else if (!IsActive(existingOverride)) + else if (!existingOverride.IsActive) { existingOverride.Activate(now); featureFlagRepository.Update(existingOverride); @@ -43,7 +43,7 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio } else { - if (existingOverride is not null && IsActive(existingOverride)) + if (existingOverride?.IsActive == true) { existingOverride.Deactivate(now); featureFlagRepository.Update(existingOverride); @@ -63,11 +63,6 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio } } - private static bool IsActive(FeatureFlag featureFlag) - { - return featureFlag.EnabledAt is not null && (featureFlag.DisabledAt is null || featureFlag.EnabledAt > featureFlag.DisabledAt); - } - private static PlanTier MapToPlanTier(SubscriptionPlan plan) { return plan switch diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 667c6b554..98cfb2632 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -147,7 +147,7 @@ public async Task ActivateFeatureFlag_AfterDeactivation_ShouldReactivateFlag() public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow() { // Arrange - var flagKey = "sso"; + var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; @@ -177,7 +177,7 @@ public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideR public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverride_ShouldCreateDisabledOverrideRow() { // Arrange - tenant has no override row (enabled via A/B rollout or default) - var flagKey = "sso"; + var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = false }; @@ -207,7 +207,7 @@ public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverrid public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_ShouldSucceed() { // Arrange - var flagKey = "sso"; + var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; @@ -228,7 +228,7 @@ public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_Shou public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDeleteRow() { // Arrange - create an override row first - var flagKey = "sso"; + var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var overrideId = FeatureFlagId.NewId().ToString(); Connection.Insert("feature_flags", [ @@ -269,7 +269,7 @@ public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDelet public async Task RemoveTenantFeatureFlagOverride_WhenNoOverrideExists_ShouldReturnNotFound() { // Arrange - var flagKey = "sso"; + var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; // Act @@ -1514,7 +1514,7 @@ public async Task DeactivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeat public async Task SetTenantFeatureFlagInternal_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() { // Arrange - var flagKey = "sso"; + var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; var originalVersion = Connection.ExecuteScalar( "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] diff --git a/application/account/Tests/Workers/FeatureFlagDefinitionReconcilerTests.cs b/application/account/Tests/Workers/FeatureFlagDefinitionReconcilerTests.cs new file mode 100644 index 000000000..9a8752226 --- /dev/null +++ b/application/account/Tests/Workers/FeatureFlagDefinitionReconcilerTests.cs @@ -0,0 +1,190 @@ +extern alias workers; +using Account.Database; +using Account.Features.FeatureFlags.Domain; +using Account.Features.Subscriptions.Domain; +using Account.Features.Users.Shared; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SharedKernel.Tests.Persistence; +using Xunit; +using FeatureFlagDefinitionReconciler = workers::Account.Workers.FeatureFlagDefinitionReconciler; + +namespace Account.Tests.Workers; + +/// +/// Backend acceptance tests for the FeatureFlagDefinitionReconciler per PP-1250. The reconciler runs +/// inline at Worker startup and converges the feature_flags table to the C# definitions in +/// SharedKernel.FeatureFlags.FeatureFlags. These tests exercise the reconciler against the +/// production code path that the existing test harness's hand-built fixtures do not cover. +/// +public sealed class FeatureFlagDefinitionReconcilerTests : EndpointBaseTest +{ + [Fact] + public async Task Reconciler_WhenPremiumTenantWithPlanOverride_ShouldEnableSsoInUserInfo() + { + // Arrange - simulate the production state after a Premium subscription: ProcessPendingStripeEvents + // would have committed a Source=Plan tenant override via MediatR UnitOfWork. We pre-insert that row + // here to verify the reconciler's contract: when the override row exists in the DB, the resulting + // UserInfo MUST carry sso. The reconciler's job is to ensure the base row is active+Source=Plan so + // the evaluator doesn't short-circuit at the base-row gate (the original C1 bug). + Connection.Update("subscriptions", "tenant_id", DatabaseSeeder.Tenant1.Id.Value, [("plan", nameof(SubscriptionPlan.Premium))]); + Connection.Insert("feature_flags", [ + ("id", FeatureFlagId.NewId().ToString()), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", "sso"), + ("tenant_id", DatabaseSeeder.Tenant1.Id.Value), + ("user_id", null), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Plan"), + ("orphaned_at", null) + ] + ); + + await RunReconcilerAsync(); + + using var scope = Provider.CreateScope(); + var userInfoFactory = scope.ServiceProvider.GetRequiredService(); + + // Act + var userInfoResult = await userInfoFactory.CreateUserInfoAsync(DatabaseSeeder.Tenant1Owner, DatabaseSeeder.Tenant1OwnerSession.Id, CancellationToken.None); + + // Assert + userInfoResult.IsSuccess.Should().BeTrue(); + userInfoResult.Value!.FeatureFlags.Should().Contain("sso", "Premium tenants with a Plan-source override must have sso in their JWT feature_flags claim"); + } + + [Fact] + public async Task Reconciler_WhenBasisTenantAndLogin_ShouldNotEnableSsoInUserInfo() + { + // Arrange - the seeder defaults the subscription plan to Basis, so no Plan-source override exists. + await RunReconcilerAsync(); + + using var scope = Provider.CreateScope(); + var userInfoFactory = scope.ServiceProvider.GetRequiredService(); + + // Act + var userInfoResult = await userInfoFactory.CreateUserInfoAsync(DatabaseSeeder.Tenant1Owner, DatabaseSeeder.Tenant1OwnerSession.Id, CancellationToken.None); + + // Assert + userInfoResult.IsSuccess.Should().BeTrue(); + userInfoResult.Value!.FeatureFlags.Should().NotContain("sso", "Basis tenants must not have sso in their JWT feature_flags claim"); + } + + [Fact] + public async Task Reconciler_WhenFlagFlipsFromManualToPlan_ShouldRemoveStaleManualTenantOverride() + { + // Arrange - the test seeder created the sso base row with default Source=Manual. Add a Manual + // tenant override for a Basis tenant to simulate the pre-PP-1250 state where the flag was + // operator-set rather than plan-derived. + var staleOverrideId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", staleOverrideId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", "sso"), + ("tenant_id", DatabaseSeeder.Tenant1.Id.Value), + ("user_id", null), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual"), + ("orphaned_at", null) + ] + ); + + // Act + await RunReconcilerAsync(); + + // Assert + Connection.RowExists("feature_flags", staleOverrideId).Should().BeFalse("the reconciler must remove Manual-source rows for a flag whose definition now requires Plan source"); + + var baseRowSource = Connection.ExecuteScalar( + "SELECT source FROM feature_flags WHERE flag_key = 'sso' AND tenant_id IS NULL AND user_id IS NULL", [] + ); + baseRowSource.Should().Be("Plan", "the reconciler must transition the base row to the expected Plan source"); + } + + [Fact] + public async Task Reconciler_WhenRunTwice_ShouldBeIdempotent() + { + // Arrange - first pass converges the seeder rows + await RunReconcilerAsync(); + var rowCountAfterFirstPass = Connection.ExecuteScalar("SELECT COUNT(*) FROM feature_flags", []); + // Capture a per-row fingerprint that changes if any column the reconciler ever writes changes. + // A bare COUNT(*) assertion would miss a regression that re-issues Update on every pass without + // adding or deleting rows; this fingerprint catches that case. + var fingerprintAfterFirstPass = Connection.ExecuteScalar( + "SELECT GROUP_CONCAT(id || ':' || COALESCE(modified_at, '') || ':' || COALESCE(enabled_at, '') || ':' || COALESCE(disabled_at, '') || ':' || source || ':' || COALESCE(orphaned_at, '')) FROM feature_flags ORDER BY id", [] + ); + + // Act + await RunReconcilerAsync(); + + // Assert + var rowCountAfterSecondPass = Connection.ExecuteScalar("SELECT COUNT(*) FROM feature_flags", []); + rowCountAfterSecondPass.Should().Be(rowCountAfterFirstPass, "a second reconciler pass must not insert or delete rows"); + + var fingerprintAfterSecondPass = Connection.ExecuteScalar( + "SELECT GROUP_CONCAT(id || ':' || COALESCE(modified_at, '') || ':' || COALESCE(enabled_at, '') || ':' || COALESCE(disabled_at, '') || ':' || source || ':' || COALESCE(orphaned_at, '')) FROM feature_flags ORDER BY id", [] + ); + fingerprintAfterSecondPass.Should().Be(fingerprintAfterFirstPass, "a second reconciler pass must not modify any row (modified_at, enabled_at, disabled_at, source, orphaned_at all unchanged)"); + } + + [Fact] + public async Task Reconciler_WhenRowExistsForRemovedFlag_ShouldMarkOrphaned() + { + // Arrange - insert a row whose flag_key is not in FeatureFlags.cs (simulating a flag removed + // from the C# definitions between deploys). + var orphanRowId = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", orphanRowId), + ("created_at", TimeProvider.GetUtcNow()), + ("modified_at", null), + ("flag_key", "removed-feature"), + ("tenant_id", null), + ("user_id", null), + ("enabled_at", TimeProvider.GetUtcNow()), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual"), + ("orphaned_at", null) + ] + ); + + // Act + await RunReconcilerAsync(); + + // Assert + var orphanedAt = Connection.ExecuteScalar( + "SELECT orphaned_at FROM feature_flags WHERE id = @id", [new { id = orphanRowId }] + ); + orphanedAt.Should().NotBeNullOrEmpty("the reconciler must mark rows whose flag_key is no longer in the C# definitions"); + + var ssoOrphanedAt = Connection.ExecuteScalar( + "SELECT orphaned_at FROM feature_flags WHERE flag_key = 'sso' AND tenant_id IS NULL AND user_id IS NULL", [] + ); + ssoOrphanedAt.Should().BeNullOrEmpty("known flag rows must never be marked orphaned"); + } + + private async Task RunReconcilerAsync() + { + using var scope = Provider.CreateScope(); + var featureFlagRepository = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var reconciler = new FeatureFlagDefinitionReconciler(featureFlagRepository, dbContext, TimeProvider, NullLogger.Instance); + await reconciler.ReconcileAsync(CancellationToken.None); + } +} diff --git a/application/account/Workers/FeatureFlagDefinitionReconciler.cs b/application/account/Workers/FeatureFlagDefinitionReconciler.cs new file mode 100644 index 000000000..9e797a99e --- /dev/null +++ b/application/account/Workers/FeatureFlagDefinitionReconciler.cs @@ -0,0 +1,110 @@ +using Account.Database; +using Account.Features.FeatureFlags.Domain; +using SharedKernel.FeatureFlags; + +namespace Account.Workers; + +/// +/// Converges the feature_flags table to the C# definitions in +/// on every worker startup. Replaces the +/// deleted one-shot SeedFeatureFlags and SeedPlanBasedFeatureFlags data migrations +/// with a converging path that handles future flag-definition changes (kill-switch flip, plan-tier +/// transitions, flag removal) without writing new data migrations. +/// For every non-System definition the reconciler ensures the global row exists with the correct +/// , ensures the global row is active when +/// is false, and removes +/// stale tenant overrides whose Source no longer matches the definition (so +/// can rebuild them on +/// the next login). Any DB row whose flag_key is not in the C# definitions is marked +/// at the current time. The reconciler is idempotent — a +/// second pass on top of a converged DB produces no changes. +/// Runs inline at Worker startup (NOT a BackgroundService) so that a failure aborts the process +/// before it accepts traffic — better to fail to start than to run with inconsistent flag state. +/// +public sealed class FeatureFlagDefinitionReconciler( + IFeatureFlagRepository featureFlagRepository, + AccountDbContext accountDbContext, + TimeProvider timeProvider, + ILogger logger +) +{ + public async Task ReconcileAsync(CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var definitions = FeatureFlags.GetAll().Where(d => d.Scope != FeatureFlagScope.System).ToArray(); + + // Load every base row up front so the per-definition reconcile loop is in-memory only. Avoids one + // round-trip per definition; the base-row count is bounded by the C# definitions, but a single + // query is still cheaper and matches how the rest of the codebase batches startup reads. + var baseRowsByKey = (await featureFlagRepository.GetAllBaseRowsAsync(cancellationToken)).ToDictionary(r => r.FlagKey); + + var baseRowsCreated = 0; + var baseRowsActivated = 0; + var sourceTransitions = 0; + var staleRowsRemoved = 0; + + foreach (var definition in definitions) + { + var expectedSource = definition.RequiredPlan is not null ? FeatureFlagSource.Plan : FeatureFlagSource.Manual; + baseRowsByKey.TryGetValue(definition.Key, out var baseRow); + + if (baseRow is null) + { + baseRow = FeatureFlag.Create(definition.Key, expectedSource); + if (!definition.IsKillSwitchEnabled) baseRow.Activate(now); + await featureFlagRepository.AddAsync(baseRow, cancellationToken); + baseRowsCreated++; + logger.LogInformation( + "Reconciler created base row for '{FlagKey}' (source '{Source}', active '{IsActive}')", + definition.Key, expectedSource, baseRow.IsActive + ); + continue; + } + + if (baseRow.Source != expectedSource) + { + var staleTenantRows = await featureFlagRepository.GetTenantOverridesForFlagAsync(definition.Key, cancellationToken); + foreach (var staleRow in staleTenantRows.Where(r => r.Source != expectedSource)) + { + featureFlagRepository.Remove(staleRow); + staleRowsRemoved++; + } + + baseRow.SetSource(expectedSource); + featureFlagRepository.Update(baseRow); + sourceTransitions++; + logger.LogInformation( + "Reconciler transitioned '{FlagKey}' source to '{Source}' and removed '{StaleCount}' stale tenant overrides", + definition.Key, expectedSource, staleTenantRows.Length + ); + } + + if (!definition.IsKillSwitchEnabled && !baseRow.IsActive) + { + baseRow.Activate(now); + featureFlagRepository.Update(baseRow); + baseRowsActivated++; + logger.LogInformation("Reconciler activated kill-switch-locked base row for '{FlagKey}'", definition.Key); + } + } + + var knownKeys = definitions.Select(d => d.Key).ToHashSet(); + var allRows = await featureFlagRepository.GetAllRowsUnfilteredAsync(cancellationToken); + + var orphansMarked = 0; + foreach (var row in allRows.Where(r => !knownKeys.Contains(r.FlagKey) && r.OrphanedAt is null)) + { + row.MarkOrphaned(now); + featureFlagRepository.Update(row); + orphansMarked++; + logger.LogInformation("Reconciler marked '{FlagKey}' (id '{Id}') as orphaned", row.FlagKey, row.Id); + } + + await accountDbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Reconciler completed: {DefinitionCount} definitions reconciled, {BaseRowsCreated} base rows created, {BaseRowsActivated} kill-switch-locked rows activated, {SourceTransitions} source transitions, {StaleRowsRemoved} stale tenant rows removed, {OrphansMarked} orphans marked", + definitions.Length, baseRowsCreated, baseRowsActivated, sourceTransitions, staleRowsRemoved, orphansMarked + ); + } +} diff --git a/application/account/Workers/Program.cs b/application/account/Workers/Program.cs index 334c20eba..50cb78d8c 100644 --- a/application/account/Workers/Program.cs +++ b/application/account/Workers/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddTransient>(); builder.Services.AddTransient>(); +builder.Services.AddTransient(); builder.Services.AddHostedService(); @@ -38,4 +39,10 @@ var dataMigrationRunner = scope.ServiceProvider.GetRequiredService>(); await dataMigrationRunner.RunMigrationsAsync(lifetime.ApplicationStopping); +// Converge the feature_flags table to the C# definitions on every Worker startup. Must complete +// successfully before the worker accepts traffic - if reconciliation throws, the process exits +// non-zero so the orchestrator notices, which is preferable to running with inconsistent flag state. +var featureFlagDefinitionReconciler = scope.ServiceProvider.GetRequiredService(); +await featureFlagDefinitionReconciler.ReconcileAsync(lifetime.ApplicationStopping); + await host.RunAsync(); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs index 5f6ee8b12..c5007c62b 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs @@ -16,7 +16,8 @@ public sealed record FeatureFlagDefinition( string? TelemetryName = null, PlanTier? RequiredPlan = null, string? SystemConfigKey = null, - string? SystemConfigExpectedValue = null + string? SystemConfigExpectedValue = null, + bool IsKillSwitchEnabled = false ) { public bool IsSystemFeatureFlagEnabled(IConfiguration configuration) diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs index e2e32161a..bb9447ed7 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -26,7 +26,8 @@ public static class FeatureFlags FeatureFlagAdminLevel.SystemAdmin, "Enables beta features for tenants", IsAbTestEligible: true, - TrackInTelemetry: true + TrackInTelemetry: true, + IsKillSwitchEnabled: true ); public static readonly FeatureFlagDefinition Sso = new( @@ -34,7 +35,8 @@ public static class FeatureFlags FeatureFlagScope.Tenant, FeatureFlagAdminLevel.SystemAdmin, "Enables single sign-on for tenants", - RequiredPlan: PlanTier.Premium + RequiredPlan: PlanTier.Premium, + IsKillSwitchEnabled: false ); public static readonly FeatureFlagDefinition CustomBranding = new( @@ -42,7 +44,8 @@ public static class FeatureFlags FeatureFlagScope.Tenant, FeatureFlagAdminLevel.TenantOwner, "Enables custom branding options for tenants", - ConfigurableByTenant: true + ConfigurableByTenant: true, + IsKillSwitchEnabled: false ); public static readonly FeatureFlagDefinition CompactView = new( @@ -50,7 +53,8 @@ public static class FeatureFlags FeatureFlagScope.User, FeatureFlagAdminLevel.User, "Enables compact view in the user interface", - ConfigurableByUser: true + ConfigurableByUser: true, + IsKillSwitchEnabled: false ); public static readonly FeatureFlagDefinition ExperimentalUi = new( @@ -59,7 +63,8 @@ public static class FeatureFlags FeatureFlagAdminLevel.User, "Enables experimental UI components for users", IsAbTestEligible: true, - TrackInTelemetry: true + TrackInTelemetry: true, + IsKillSwitchEnabled: true ); private static readonly FeatureFlagDefinition[] AllFeatureFlags = [GoogleOauth, Subscriptions, BetaFeatures, Sso, CustomBranding, CompactView, ExperimentalUi]; @@ -151,6 +156,11 @@ private static void ValidateFlags() { throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with RequiredPlan cannot be IsAbTestEligible."); } + + if (featureFlag.IsKillSwitchEnabled) + { + throw new InvalidOperationException($"Feature flag '{featureFlag.Key}' with RequiredPlan cannot be IsKillSwitchEnabled - plan-gated flags must always be platform-managed."); + } } if (featureFlag.ParentDependency is not null) diff --git a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs index cc85b4d95..c777a9300 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs @@ -69,6 +69,10 @@ public void Initialize(ITelemetry telemetry) AddCustomProperty(telemetry, "user.role", executionContext.UserInfo.Role); AddCustomProperty(telemetry, "user.session_id", executionContext.UserInfo.SessionId?.Value); + // Iteration is over current C# definitions only; orphaned flag keys (DB rows whose key was removed + // from FeatureFlags.cs) cannot reach telemetry because FeatureFlagDefinitionReconciler marks them + // OrphanedAt at startup and they are no longer in GetAll(). If a future change loads flags from the + // database instead of definitions, the orphan filter must be re-introduced here explicitly. foreach (var featureFlag in FeatureFlags.FeatureFlags.GetAll()) { if (!featureFlag.TrackInTelemetry) continue; diff --git a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs index 7b22b623d..d364702db 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs @@ -35,6 +35,10 @@ public void Apply() Activity.Current.SetTag("user.role", executionContext.UserInfo.Role); Activity.Current.SetTag("user.session_id", executionContext.UserInfo.SessionId?.Value); + // Iteration is over current C# definitions only; orphaned flag keys (DB rows whose key was removed + // from FeatureFlags.cs) cannot reach telemetry because FeatureFlagDefinitionReconciler marks them + // OrphanedAt at startup and they are no longer in GetAll(). If a future change loads flags from the + // database instead of definitions, the orphan filter must be re-introduced here explicitly. foreach (var featureFlag in FeatureFlags.FeatureFlags.GetAll()) { if (!featureFlag.TrackInTelemetry) continue; From 13d17672dc73419c86090dd3caaa1373d59f7cf4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 17:00:09 +0200 Subject: [PATCH 054/155] Delete obsolete internal-api feature-flag routes and gate back-office kill-switch with AdminPolicyName --- .../BlockInternalApiTransform.cs | 3 + .../Api/BackOffice/FeatureFlagEndpoints.cs | 6 +- .../Api/Endpoints/FeatureFlagEndpoints.cs | 45 - .../account/Api/Endpoints/TenantEndpoints.cs | 5 - .../Features/Tenants/Queries/GetTenants.cs | 28 - .../EndpointMetadataTests.cs | 88 + .../InternalApiRoutesRemovedTests.cs | 40 + .../FeatureFlagBackOfficeTests.cs | 1270 ++++++++++++++ .../GetFeatureFlagTenantsBackOfficeTests.cs | 90 - .../Tests/FeatureFlags/FeatureFlagTests.cs | 1500 +---------------- .../account/Tests/Tenants/GetTenantsTests.cs | 37 - 11 files changed, 1481 insertions(+), 1631 deletions(-) delete mode 100644 application/account/Core/Features/Tenants/Queries/GetTenants.cs create mode 100644 application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs create mode 100644 application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs delete mode 100644 application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs delete mode 100644 application/account/Tests/Tenants/GetTenantsTests.cs diff --git a/application/AppGateway/Transformations/BlockInternalApiTransform.cs b/application/AppGateway/Transformations/BlockInternalApiTransform.cs index e2157170f..79a1e097c 100644 --- a/application/AppGateway/Transformations/BlockInternalApiTransform.cs +++ b/application/AppGateway/Transformations/BlockInternalApiTransform.cs @@ -11,6 +11,9 @@ public override async ValueTask ApplyAsync(RequestTransformContext context) context.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; context.HttpContext.Response.ContentType = "text/plain"; await context.HttpContext.Response.WriteAsync("Access to internal API is forbidden."); + // Finalize the response so the YARP forwarder cannot pick up the request and forward it + // upstream after we've already written the 403 body. + await context.HttpContext.Response.CompleteAsync(); } } } diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index c9ba1343a..9b37dbe8e 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -32,13 +32,15 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); + // Kill-switch mutations: reserved for the configured admin group. Back-office is not admin-only; + // tightening these two endpoints prevents non-admin staff from flipping a flag fleet-wide. group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) - ).DisableAntiforgery(); + ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); group.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) - ).DisableAntiforgery(); + ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index 302649844..af4cc09fc 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -1,7 +1,6 @@ using Account.Features.FeatureFlags.Commands; using Account.Features.FeatureFlags.Queries; using SharedKernel.ApiResults; -using SharedKernel.Domain; using SharedKernel.Endpoints; using SharedKernel.OpenApi; @@ -11,50 +10,6 @@ public sealed class FeatureFlagEndpoints : IEndpoints { public void MapEndpoints(IEndpointRouteBuilder routes) { - // Internal API endpoints (back-office operations, no auth group) - var internalGroup = routes.MapGroup("/internal-api/account/feature-flags").WithGroupName(OpenApiDocumentNames.Account); - - internalGroup.MapGet("/", async Task> (IMediator mediator) - => await mediator.Send(new GetFeatureFlagsQuery()) - ).Produces(); - - internalGroup.MapGet("/{flagKey}/tenants", async Task> (string flagKey, [AsParameters] GetFeatureFlagTenantsQuery query, IMediator mediator) - => await mediator.Send(query with { FlagKey = flagKey }) - ).Produces(); - - internalGroup.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) - => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) - ).DisableAntiforgery(); - - internalGroup.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) - => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) - ).DisableAntiforgery(); - - internalGroup.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) - => await mediator.Send(command with { FlagKey = flagKey }) - ).DisableAntiforgery(); - - internalGroup.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) - => await mediator.Send(command with { FlagKey = flagKey }) - ).DisableAntiforgery(); - - internalGroup.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, TenantId tenantId, IMediator mediator) - => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) - ).DisableAntiforgery(); - - internalGroup.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFeatureFlagUsersQuery query, IMediator mediator) - => await mediator.Send(query with { FlagKey = flagKey }) - ).Produces(); - - internalGroup.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) - => await mediator.Send(command with { FlagKey = flagKey }) - ).DisableAntiforgery(); - - internalGroup.MapDelete("/{flagKey}/user-override", async Task (string flagKey, UserId userId, TenantId tenantId, IMediator mediator) - => await mediator.Send(new RemoveUserFeatureFlagOverrideCommand { FlagKey = flagKey, UserId = userId, TenantId = tenantId }) - ).DisableAntiforgery(); - - // Authenticated API endpoints (tenant owner and user operations) var group = routes.MapGroup("/api/account/feature-flags").WithTags("FeatureFlags").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem(); group.MapGet("/tenant-configurable", async Task> (IMediator mediator) diff --git a/application/account/Api/Endpoints/TenantEndpoints.cs b/application/account/Api/Endpoints/TenantEndpoints.cs index 9448982e0..82643ca23 100644 --- a/application/account/Api/Endpoints/TenantEndpoints.cs +++ b/application/account/Api/Endpoints/TenantEndpoints.cs @@ -36,11 +36,6 @@ public void MapEndpoints(IEndpointRouteBuilder routes) ); // Internal-only endpoint reachable backend-to-backend via the cluster's localhost address. - // BlockInternalApiTransform in AppGateway rejects external callers. - routes.MapGet("/internal-api/account/tenants", async Task> (IMediator mediator) - => await mediator.Send(new GetTenantsQuery()) - ).Produces().WithGroupName(OpenApiDocumentNames.Account); - routes.MapDelete("/internal-api/account/tenants/{id}", async Task (TenantId id, IMediator mediator) => await mediator.Send(new DeleteTenantCommand(id)) ).WithGroupName(OpenApiDocumentNames.Account); diff --git a/application/account/Core/Features/Tenants/Queries/GetTenants.cs b/application/account/Core/Features/Tenants/Queries/GetTenants.cs deleted file mode 100644 index 51700ca89..000000000 --- a/application/account/Core/Features/Tenants/Queries/GetTenants.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Account.Features.Tenants.Domain; -using JetBrains.Annotations; -using SharedKernel.Cqrs; -using SharedKernel.Domain; - -namespace Account.Features.Tenants.Queries; - -[PublicAPI] -public sealed record GetTenantsQuery : IRequest>; - -[PublicAPI] -public sealed record GetTenantsResponse(TenantSummary[] Tenants); - -[PublicAPI] -public sealed record TenantSummary(TenantId Id, string Name); - -public sealed class GetTenantsHandler(ITenantRepository tenantRepository) - : IRequestHandler> -{ - public async Task> Handle(GetTenantsQuery query, CancellationToken cancellationToken) - { - var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); - - var tenantSummaries = tenants.Select(t => new TenantSummary(t.Id, t.Name)).ToArray(); - - return new GetTenantsResponse(tenantSummaries); - } -} diff --git a/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs b/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs index bc23b3a01..0e91a591c 100644 --- a/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs +++ b/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs @@ -1,10 +1,13 @@ using FluentAssertions; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SharedKernel.Authentication.BackOfficeIdentity; using SharedKernel.OpenApi; +using SharedKernel.SinglePageApp; using Xunit; namespace Account.Tests.ArchitectureTests; @@ -22,11 +25,47 @@ public sealed class EndpointMetadataTests : IDisposable { private const string AppHost = "app.test.localhost"; private const string BackOfficeHost = "back-office.test.localhost"; + private const string TestPublicUrl = "https://localhost"; + + // Anonymous-by-design /internal-api/* endpoints. Adding a new entry requires a documented + // rationale alongside the route key: the endpoint either has no credential available in-cluster + // (ACA health probes), is the credential issuance route itself (refresh-authentication-tokens), + // is a backend-to-backend call from downstream main SCS projects that cannot pass an auth token + // today, or is the framework-level catch-all that returns 404 for unmatched /internal-api/* paths. + private static readonly string[] AnonymousInternalApiAllowlist = + [ + // Refresh-authentication-tokens: anonymous by design — the refresh token in the request body + // is the bearer credential. AppGateway's AuthenticationCookieMiddleware re-issues cookies via + // this route when an upstream sets `x-refresh-authentication-tokens-required`. + "POST:/internal-api/account/authentication/refresh-authentication-tokens", + // ACA container app liveness + readiness probes. The probes do not carry credentials. + "GET:/internal-api/live", + "GET:/internal-api/ready", + // /internal-api/account/tenants/{id} stays anonymous: server-to-server call from main SCS in + // downstream projects (DataMentor, ProductConnect). Until downstream callers can pass an auth + // token, the BlockInternalApiTransform + ACA private ingress are the perimeter. + "DELETE:/internal-api/account/tenants/{id}", + // SinglePageAppFallbackExtensions registers a framework-level catch-all that emits 404 for + // any unmatched /internal-api/* path. The 404 emitter is not a callable endpoint. + "GET:/internal-api/{**_}", + "POST:/internal-api/{**_}", + "PUT:/internal-api/{**_}", + "DELETE:/internal-api/{**_}", + "PATCH:/internal-api/{**_}", + "HEAD:/internal-api/{**_}", + "OPTIONS:/internal-api/{**_}" + ]; private readonly WebApplicationFactory _webApplicationFactory; public EndpointMetadataTests() { + // SinglePageAppConfiguration reads these env vars on host start to build the CSP. Without + // them, host construction throws "Invalid URI: The URI is empty" before the endpoint data + // source becomes available. The values are throwaway — only their well-formedness matters. + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl); + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/account"); + _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); @@ -120,6 +159,55 @@ public void BackOfficeEndpoints_ShouldAllDeclareBackOfficeGroupName() endpointsMissingGroupName.Should().BeEmpty("back-office endpoints must declare WithGroupName(\"back-office\") so they appear in the back-office OpenAPI document"); } + [Fact] + public void InternalApiEndpoints_ShouldEitherRequireAuthorizationOrBeOnAllowlist() + { + // Arrange + var routeEndpoints = GetRouteEndpoints(); + var internalApiEndpoints = routeEndpoints + .Where(endpoint => endpoint.RoutePattern.RawText is { } pattern && pattern.StartsWith("/internal-api/", StringComparison.OrdinalIgnoreCase)) + .ToList(); + internalApiEndpoints.Should().NotBeEmpty("the account host must register at least one /internal-api/* endpoint (refresh-tokens, health probes)"); + + // Assert + var violations = internalApiEndpoints + .Where(endpoint => endpoint.Metadata.GetMetadata() is null) + .Where(endpoint => !AnonymousInternalApiAllowlist.Contains(BuildEndpointKey(endpoint))) + .Select(BuildEndpointKey) + .ToList(); + violations.Should().BeEmpty( + "every /internal-api/* endpoint must either declare RequireAuthorization, or be added to AnonymousInternalApiAllowlist with a documented rationale. Anonymous /internal-api/* endpoints bypass BlockInternalApiTransform on pod-to-pod traffic and have been the source of cross-tenant data-exposure regressions." + ); + } + + [Fact] + public void BackOfficeWriteEndpoints_ShouldDeclareExplicitAuthorizationPolicy() + { + // Arrange + var routeEndpoints = GetRouteEndpoints(); + var backOfficeWriteEndpoints = routeEndpoints + .Where(endpoint => endpoint.RoutePattern.RawText is { } pattern && pattern.StartsWith("/api/back-office/", StringComparison.OrdinalIgnoreCase)) + .Where(endpoint => endpoint.Metadata.GetMetadata()?.HttpMethods.Any(method => method is "POST" or "PUT" or "PATCH" or "DELETE") == true) + .ToList(); + backOfficeWriteEndpoints.Should().NotBeEmpty("the back-office route group must register at least one write endpoint"); + + // Assert + string[] allowedPolicies = [BackOfficeIdentityDefaults.PolicyName, BackOfficeIdentityDefaults.AdminPolicyName]; + var violations = backOfficeWriteEndpoints + .Where(endpoint => endpoint.Metadata.GetOrderedMetadata().All(data => data.Policy is null || !allowedPolicies.Contains(data.Policy))) + .Select(BuildEndpointKey) + .ToList(); + violations.Should().BeEmpty( + $"every back-office write endpoint must declare RequireAuthorization with either '{BackOfficeIdentityDefaults.PolicyName}' (regular back-office) or '{BackOfficeIdentityDefaults.AdminPolicyName}' (admin-only, e.g. kill-switches). New endpoints inherit the group-level PolicyName by default; the test catches accidental .AllowAnonymous() or missing-policy regressions." + ); + } + + private static string BuildEndpointKey(RouteEndpoint endpoint) + { + var method = endpoint.Metadata.GetMetadata()?.HttpMethods.FirstOrDefault() ?? "GET"; + return $"{method}:{endpoint.RoutePattern.RawText}"; + } + private List GetRouteEndpoints() { return _webApplicationFactory.Services diff --git a/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs b/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs new file mode 100644 index 000000000..ad28db92e --- /dev/null +++ b/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Database; +using FluentAssertions; +using Xunit; + +namespace Account.Tests.ArchitectureTests; + +// Regression guard for PP-1251. The /internal-api/account/feature-flags/* routes were unauthenticated +// and unhost-pinned, and the legacy /internal-api/account/tenants GET exposed cross-tenant data. +// The InternalApiEndpoints_ShouldEitherRequireAuthorizationOrBeOnAllowlist arch test enforces the +// auth-or-allowlist invariant going forward; this Theory locks in that the specific routes deleted +// by PP-1251 stay deleted (a future contributor cannot accidentally remap them). +public sealed class InternalApiRoutesRemovedTests : EndpointBaseTest +{ + [Theory] + [InlineData("GET", "/internal-api/account/feature-flags")] + [InlineData("GET", "/internal-api/account/feature-flags/beta-features/tenants")] + [InlineData("GET", "/internal-api/account/feature-flags/beta-features/users")] + [InlineData("PUT", "/internal-api/account/feature-flags/beta-features/activate")] + [InlineData("PUT", "/internal-api/account/feature-flags/beta-features/deactivate")] + [InlineData("PUT", "/internal-api/account/feature-flags/beta-features/tenant-override")] + [InlineData("PUT", "/internal-api/account/feature-flags/beta-features/rollout-percentage")] + [InlineData("DELETE", "/internal-api/account/feature-flags/beta-features/tenant-override")] + [InlineData("PUT", "/internal-api/account/feature-flags/beta-features/user-override")] + [InlineData("DELETE", "/internal-api/account/feature-flags/beta-features/user-override")] + [InlineData("GET", "/internal-api/account/tenants")] + public async Task DeletedInternalApiRoute_ShouldReturnNotFound(string method, string path) + { + // Act - anonymous client because these routes were anonymous when they existed; if the route + // were silently re-registered with auth, an anonymous caller would see 401 not 404 and the + // test would still fail loudly. + var request = new HttpRequestMessage(new HttpMethod(method), path); + if (method is "PUT" or "POST") request.Content = JsonContent.Create(new { }); + var response = await AnonymousHttpClient.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"the {method} {path} route was deleted by PP-1251 and must not be re-introduced"); + } +} diff --git a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs new file mode 100644 index 000000000..50e5bfe50 --- /dev/null +++ b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs @@ -0,0 +1,1270 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Features.FeatureFlags.Commands; +using Account.Features.FeatureFlags.Domain; +using Account.Features.FeatureFlags.Queries; +using Account.Features.Subscriptions.Domain; +using Account.Features.Users.Domain; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests; +using SharedKernel.Tests.Persistence; +using Xunit; +using FeatureFlagRegistry = SharedKernel.FeatureFlags.FeatureFlags; +using FeatureFlagScope = SharedKernel.FeatureFlags.FeatureFlagScope; + +namespace Account.Tests.BackOffice.FeatureFlags; + +// Exercises the back-office feature-flag endpoints at /api/back-office/feature-flags/*. These used to +// live on the unauthenticated /internal-api/account/feature-flags/* group and were deleted by +// PP-1251. Activate/deactivate carry an extra AdminPolicyName requirement (fleet-wide kill-switch); +// everything else uses the regular back-office identity policy. +public sealed class FeatureFlagBackOfficeTests : BackOfficeEndpointBaseTest +{ + private const string RegularBackOfficeIdentityId = "user"; + private const string AdminBackOfficeIdentityId = "admin"; + + private HttpClient CreateRegularBackOfficeClient() + { + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == RegularBackOfficeIdentityId); + return CreateBackOfficeClientForIdentity(identity); + } + + private HttpClient CreateAdminBackOfficeClient() + { + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == AdminBackOfficeIdentityId); + return CreateBackOfficeClientForIdentity(identity); + } + + // Activate / deactivate kill-switch (AdminPolicyName) + + [Fact] + public async Task ActivateFeatureFlag_WhenAdmin_ShouldSetEnabledAt() + { + // Arrange + var flagKey = "sso"; + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + enabledAt.Should().NotBeNullOrEmpty(); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagActivated"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task ActivateFeatureFlag_WhenAlreadyActiveAndAdmin_ShouldUpdateEnabledAt() + { + // Arrange + var flagKey = "beta-features"; + var originalEnabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedEnabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + updatedEnabledAt.Should().NotBe(originalEnabledAt); + } + + [Fact] + public async Task ActivateFeatureFlag_WhenNonAdminBackOfficeIdentity_ShouldReturnForbidden() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/activate", null); + + // Assert - the activate route is gated by AdminPolicyName, so a regular back-office identity + // without the admin group claim must be rejected. + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenAdmin_ShouldSetDisabledAt() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + disabledAt.Should().NotBeNullOrEmpty(); + enabledAt.Should().NotBeNullOrEmpty("EnabledAt should be preserved on deactivation"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagDeactivated"); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenAlreadyInactiveAndAdmin_ShouldHandleGracefully() + { + // Arrange + var flagKey = "sso"; + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenNonAdminBackOfficeIdentity_ShouldReturnForbidden() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task ActivateFeatureFlag_AfterDeactivationByAdmin_ShouldReactivateFlag() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateAdminBackOfficeClient(); + await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/deactivate", null); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + disabledAt.Should().BeNull("DisabledAt should be cleared on reactivation"); + } + + // Tenant override (regular PolicyName) + + [Fact] + public async Task SetTenantOverride_WhenEnabledAsRegularBackOffice_ShouldCreateOverrideRow() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + using var client = CreateRegularBackOfficeClient(); + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId = tenantId.Value }] + ); + rowCount.Should().Be(1); + var enabledAt = Connection.ExecuteScalar( + "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId = tenantId.Value }] + ); + enabledAt.Should().NotBeNullOrEmpty(); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagTenantOverrideSet"); + } + + [Fact] + public async Task SetTenantOverride_WhenDisabledWithNoExistingOverride_ShouldCreateDisabledOverrideRow() + { + // Arrange - tenant has no override row (enabled via A/B rollout or default). + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + using var client = CreateRegularBackOfficeClient(); + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = false }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId = tenantId.Value }] + ); + rowCount.Should().Be(1); + var disabledAt = Connection.ExecuteScalar( + "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId = tenantId.Value }] + ); + disabledAt.Should().BeNull("a newly created disabled override should not have disabled_at set when never activated"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagTenantOverrideRemoved"); + } + + [Fact] + public async Task RemoveTenantOverride_WhenOverrideExists_ShouldDeleteRow() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId = tenantId.Value }] + ); + rowCount.Should().Be(0); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagTenantOverrideRemoved"); + } + + [Fact] + public async Task RemoveTenantOverride_WhenNoOverrideExists_ShouldReturnNotFound() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // User override (regular PolicyName) + + [Fact] + public async Task SetUserOverride_WhenEnabledAsRegularBackOffice_ShouldCreateOverrideRow() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id; + var tenantId = DatabaseSeeder.Tenant1.Id; + using var client = CreateRegularBackOfficeClient(); + var command = new SetUserFeatureFlagInternalCommand { UserId = userId, TenantId = tenantId, Enabled = true }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/user-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId = userId.Value }] + ); + rowCount.Should().Be(1); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagUserOverrideSet"); + } + + [Fact] + public async Task RemoveUserOverride_WhenOverrideExists_ShouldDeleteRow() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertUserOverride(flagKey, tenantId, userId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId }] + ); + rowCount.Should().Be(0); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagUserOverrideRemoved"); + } + + [Fact] + public async Task RemoveUserOverride_WhenNoOverrideExists_ShouldReturnNotFound() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var tenantId = DatabaseSeeder.Tenant1.Id; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // Rollout percentage (regular PolicyName per PP-1251 — not admin-tier) + + [Fact] + public async Task SetRolloutPercentage_WhenValidPercentageAsRegularBackOffice_ShouldUpdateBucketRange() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateRegularBackOfficeClient(); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rolloutBucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var rolloutBucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + rolloutBucketStart.Should().NotBeNull(); + rolloutBucketEnd.Should().NotBeNull(); + CountBucketsInRange((int)rolloutBucketStart.Value, (int)rolloutBucketEnd.Value).Should().Be(50); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagRolloutPercentageUpdated"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.rollout_percentage"].Should().Be("50"); + } + + [Theory] + [InlineData(1)] + [InlineData(99)] + public async Task SetRolloutPercentage_WhenSetToNPercent_ShouldIncludeExactlyNBuckets(int percentage) + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateRegularBackOfficeClient(); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = percentage }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rolloutBucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var rolloutBucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + CountBucketsInRange((int)rolloutBucketStart!.Value, (int)rolloutBucketEnd!.Value).Should().Be(percentage); + } + + [Fact] + public async Task SetRolloutPercentage_WhenInvalidPercentage_ShouldFailValidation() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateRegularBackOfficeClient(); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 101 }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SetRolloutPercentage_WhenNonAbTestEligibleFlag_ShouldFailValidation() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SetRolloutPercentage_WhenZeroPercent_ShouldClearBucketRange() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateRegularBackOfficeClient(); + await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 0 }); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rolloutBucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var rolloutBucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + rolloutBucketStart.Should().BeNull(); + rolloutBucketEnd.Should().BeNull(); + } + + [Fact] + public async Task SetRolloutPercentage_WhenHundredPercent_ShouldSetFullRange() + { + // Arrange + var flagKey = "beta-features"; + using var client = CreateRegularBackOfficeClient(); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 100 }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var rolloutBucketStart = Connection.ExecuteScalar( + "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + var rolloutBucketEnd = Connection.ExecuteScalar( + "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + rolloutBucketStart.Should().Be(0); + rolloutBucketEnd.Should().Be(99); + } + + // Flag tenants query (regular PolicyName) + + [Fact] + public async Task GetFeatureFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWithDefaultSource() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotBeEmpty(); + result.Tenants.Should().AllSatisfy(t => + { + t.Source.Should().Be("default"); + t.IsEnabled.Should().BeFalse(); + } + ); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrideSource() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); + tenantResult.IsEnabled.Should().BeTrue(); + tenantResult.Source.Should().Be("manual_override"); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource() + { + // Arrange + var flagKey = "beta-features"; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 99) + ] + ); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().AllSatisfy(t => + { + t.Source.Should().Be("ab_rollout"); + t.IsEnabled.Should().BeTrue(); + } + ); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActive_ShouldReturnManualOverrideDisabled() + { + // Arrange - 100% rollout means every tenant is enabled, then add a disabled manual override. + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 99) + ] + ); + InsertTenantOverride(flagKey, tenantId, false); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert - manual override takes precedence over the A/B rollout. + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); + tenantResult.IsEnabled.Should().BeFalse(); + tenantResult.Source.Should().Be("manual_override"); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() + { + using var client = CreateRegularBackOfficeClient(); + var response = await client.GetAsync("/api/back-office/feature-flags/non-existent/tenants"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenSystemScopedFlag_ShouldReturnBadRequest() + { + using var client = CreateRegularBackOfficeClient(); + var response = await client.GetAsync("/api/back-office/feature-flags/google-oauth/tenants"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenSearchMatchesOwnerEmail_ShouldReturnMatchingTenants() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act - DatabaseSeeder.Tenant1Owner email is "owner@tenant-1.com", so "tenant-1" hits owner email. + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?Search=tenant-1"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotBeEmpty(); + result.Tenants.Should().OnlyContain(t => t.Owner!.Email.Contains("tenant-1")); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenSearchHasNoMatches_ShouldReturnEmpty() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?Search=does-not-exist-anywhere"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenPlansFilterMatches_ShouldReturnOnlyTenantsOnSelectedPlans() + { + // Arrange - DatabaseSeeder.Tenant1 is on the Basis plan. + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var matchResponse = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?Plans=Basis"); + var noMatchResponse = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?Plans=Premium"); + + // Assert + matchResponse.ShouldBeSuccessfulGetRequest(); + noMatchResponse.ShouldBeSuccessfulGetRequest(); + var matchResult = await matchResponse.DeserializeResponse(); + var noMatchResult = await noMatchResponse.DeserializeResponse(); + matchResult!.Tenants.Should().OnlyContain(t => t.Plan == SubscriptionPlan.Basis); + matchResult.TotalCount.Should().BeGreaterThan(0); + noMatchResult!.Tenants.Should().BeEmpty(); + noMatchResult.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenStateEnabledAndTenantHasManualOverride_ShouldReturnOnlyEnabledTenants() + { + // Arrange - tenant-1 gets a manual enable for `sso`; other tenants remain disabled. + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?State=Enabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.IsEnabled); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenStateDisabledAndTenantIsEnabledViaOverride_ShouldExcludeTenant() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?State=Disabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().NotContain(t => t.Id.Value == tenantId); + result.Tenants.Where(t => t.IsEnabled).Should().BeEmpty(); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenStateOmitted_ShouldNotFilterByState() + { + // Arrange - tenant-1 gets enabled, then ask without State. + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var omittedResponse = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + var enabledResponse = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?State=Enabled"); + + // Assert + omittedResponse.ShouldBeSuccessfulGetRequest(); + enabledResponse.ShouldBeSuccessfulGetRequest(); + var omitted = await omittedResponse.DeserializeResponse(); + var enabled = await enabledResponse.DeserializeResponse(); + omitted!.TotalCount.Should().BeGreaterThanOrEqualTo(enabled!.TotalCount); + omitted.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenPaginated_ShouldReturnCorrectPagingMetadata() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?PageSize=1&PageOffset=0"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.PageSize.Should().Be(1); + result.CurrentPageOffset.Should().Be(0); + result.Tenants.Length.Should().BeLessOrEqualTo(1); + result.TotalPages.Should().Be(result.TotalCount); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenSortedByName_ShouldReturnTenantsInAscendingNameOrder() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().BeInAscendingOrder(t => t.Name); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideOmitted_ShouldNotFilterByOverride() + { + // Arrange - seed an override so the dataset has both override and non-override rows. + var flagKey = "sso"; + InsertTenantOverride(flagKey, DatabaseSeeder.Tenant1.Id, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().Contain(t => t.Source == "manual_override"); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrue_ShouldReturnOnlyTenantsWithManualOverride() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndStateDisabled_ShouldReturnDisabledOverrides() + { + // Arrange - tenant-1 has a disabling manual override. + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + InsertTenantOverride(flagKey, tenantId, false); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?HasOverride=true&State=Disabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override" && !t.IsEnabled); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndNoTenantHasOverride_ShouldReturnEmpty() + { + // Arrange + var flagKey = "sso"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetFeatureFlagTenants_WhenHasOverrideTrueOnAbRolloutFlag_ShouldExcludeAbRolloutAndDefaultRows() + { + // Arrange - beta-features at 100% rollout means every tenant is "ab_rollout"; the manual + // override for tenant-1 should be the only row HasOverride=true keeps. + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var baseRowId = Connection.ExecuteScalar( + "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + Connection.Update("feature_flags", "id", baseRowId, [ + ("bucket_start", 0), + ("bucket_end", 99) + ] + ); + InsertTenantOverride(flagKey, tenantId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().Contain(t => t.Id.Value == tenantId); + } + + // Flag users query (regular PolicyName) + + [Fact] + public async Task GetFeatureFlagUsers_WhenNoSearchProvided_ShouldReturnAllUsersPaginated() + { + // Arrange + var flagKey = "compact-view"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + result.TotalCount.Should().BeGreaterThan(0); + result.PageSize.Should().Be(25); + result.CurrentPageOffset.Should().Be(0); + result.TotalPages.Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchingUsersWithDefaultSource() + { + // Arrange + var flagKey = "compact-view"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?search=owner@tenant-1"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + result.Users.Should().OnlyContain(u => u.Source == "default"); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOverrideSource() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?search=owner@tenant-1"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + var userResult = result.Users.Single(u => u.Id.Value == userId); + userResult.IsEnabled.Should().BeTrue(); + userResult.Source.Should().Be("manual_override"); + userResult.Email.Should().NotBe("Unknown"); + userResult.TenantName.Should().NotBe("Unknown"); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenNonUserScopedFlag_ShouldReturnBadRequest() + { + using var client = CreateRegularBackOfficeClient(); + var response = await client.GetAsync("/api/back-office/feature-flags/sso/users"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenSearchOnlyMatchingEmail_ShouldReturnOnlyMatchingUsers() + { + // Arrange + var flagKey = "compact-view"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?Search=owner@tenant-1"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + result.Users.Should().OnlyContain(u => u.Email.Contains("owner@tenant-1")); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenRolesFilterApplied_ShouldReturnOnlyUsersWithSelectedRoles() + { + // Arrange + var flagKey = "compact-view"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?Roles=Owner"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().NotBeEmpty(); + result.Users.Should().OnlyContain(u => u.Role == UserRole.Owner); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenStateEnabledAndUserHasManualOverride_ShouldReturnOnlyEnabledUsers() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?State=Enabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().OnlyContain(u => u.IsEnabled); + result.Users.Should().Contain(u => u.Id.Value == userId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenStateDisabled_ShouldReturnOnlyDisabledUsers() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?State=Disabled"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().OnlyContain(u => !u.IsEnabled); + result.Users.Should().NotContain(u => u.Id.Value == userId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenPaginated_ShouldReturnCorrectPagingMetadata() + { + // Arrange + var flagKey = "compact-view"; + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?PageSize=1&PageOffset=0"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.PageSize.Should().Be(1); + result.CurrentPageOffset.Should().Be(0); + result.Users.Length.Should().BeLessOrEqualTo(1); + result.TotalPages.Should().Be(result.TotalCount); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenPageOffsetExceedsTotalPages_ShouldReturnBadRequest() + { + using var client = CreateRegularBackOfficeClient(); + var response = await client.GetAsync("/api/back-office/feature-flags/compact-view/users?PageOffset=100"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenHasOverrideOmitted_ShouldNotFilterByOverride() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().Contain(u => u.Source == "manual_override"); + result.Users.Should().Contain(u => u.Source == "default"); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenHasOverrideTrue_ShouldReturnOnlyUsersWithManualOverride() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?HasOverride=true"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Users.Should().OnlyContain(u => u.Source == "manual_override"); + result.Users.Should().Contain(u => u.Id.Value == userId); + } + + [Fact] + public async Task GetFeatureFlagUsers_WhenHasOverrideTrueAndRoleFiltered_ShouldReturnOnlyMatchingUsersWithOverride() + { + // Arrange - only the owner gets the override. + var flagKey = "compact-view"; + var ownerId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, ownerId, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var matchResponse = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?HasOverride=true&Roles=Owner"); + var noMatchResponse = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/users?HasOverride=true&Roles=Member"); + + // Assert + matchResponse.ShouldBeSuccessfulGetRequest(); + noMatchResponse.ShouldBeSuccessfulGetRequest(); + var matchResult = await matchResponse.DeserializeResponse(); + var noMatchResult = await noMatchResponse.DeserializeResponse(); + matchResult!.Users.Should().OnlyContain(u => u.Source == "manual_override" && u.Role == UserRole.Owner); + matchResult.Users.Should().Contain(u => u.Id.Value == ownerId); + noMatchResult!.Users.Should().BeEmpty(); + } + + // List-all-flags query (regular PolicyName) + + [Fact] + public async Task GetFeatureFlags_WhenCalled_ShouldReturnAllFlagsWithDatabaseState() + { + // Arrange + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.GetAsync("/api/back-office/feature-flags"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Flags.Should().HaveCount(FeatureFlagRegistry.GetAll().Length); + + result.Flags.Single(f => f.Key == "google-oauth").Scope.Should().Be(FeatureFlagScope.System); + result.Flags.Single(f => f.Key == "subscriptions").Scope.Should().Be(FeatureFlagScope.System); + var betaFeatures = result.Flags.Single(f => f.Key == "beta-features"); + betaFeatures.Scope.Should().Be(FeatureFlagScope.Tenant); + betaFeatures.IsAbTestEligible.Should().BeTrue(); + betaFeatures.IsActive.Should().BeTrue(); + result.Flags.Single(f => f.Key == "sso").IsActive.Should().BeFalse(); + result.Flags.Single(f => f.Key == "custom-branding").ConfigurableByTenant.Should().BeTrue(); + result.Flags.Single(f => f.Key == "compact-view").ConfigurableByUser.Should().BeTrue(); + } + + // JWT-invalidation: kill-switch and per-tenant override both bump feature_flag_version. + + [Fact] + public async Task ActivateFeatureFlag_WhenCalledByAdmin_ShouldIncrementAllTenantsFeatureFlagVersion() + { + // Arrange + var flagKey = "sso"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/activate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task DeactivateFeatureFlag_WhenCalledByAdmin_ShouldIncrementAllTenantsFeatureFlagVersion() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.PutAsync($"/api/back-office/feature-flags/{flagKey}/deactivate", null); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task SetTenantOverride_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + using var client = CreateRegularBackOfficeClient(); + var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + [Fact] + public async Task SetRolloutPercentage_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() + { + // Arrange + var flagKey = "beta-features"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + using var client = CreateRegularBackOfficeClient(); + var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/rollout-percentage", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + updatedVersion.Should().Be(originalVersion + 1); + } + + private void InsertTenantOverride(string flagKey, TenantId tenantId, bool enabled) + { + var overrideId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", null), + ("enabled_at", enabled ? now : null), + ("disabled_at", enabled ? null : now), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + } + + private void InsertUserOverride(string flagKey, TenantId tenantId, string userId, bool enabled) + { + var overrideId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", overrideId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", tenantId.Value), + ("user_id", userId), + ("enabled_at", enabled ? now : null), + ("disabled_at", enabled ? null : now), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + } + + private static int CountBucketsInRange(int rolloutBucketStart, int rolloutBucketEnd) + { + if (rolloutBucketStart <= rolloutBucketEnd) return rolloutBucketEnd - rolloutBucketStart + 1; + return 100 - rolloutBucketStart + rolloutBucketEnd + 1; + } +} diff --git a/application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs deleted file mode 100644 index 62c0fa282..000000000 --- a/application/account/Tests/BackOffice/FeatureFlags/GetFeatureFlagTenantsBackOfficeTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using Account.Features.FeatureFlags.Domain; -using Account.Features.FeatureFlags.Queries; -using FluentAssertions; -using SharedKernel.Authentication.MockEasyAuth; -using SharedKernel.Tests.Persistence; -using Xunit; - -namespace Account.Tests.BackOffice.FeatureFlags; - -public sealed class GetFeatureFlagTenantsBackOfficeTests : BackOfficeEndpointBaseTest -{ - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideTrueOnBackOfficeRoute_ShouldReturnOnlyTenantsWithManualOverride() - { - // Arrange - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var overrideId = FeatureFlagId.NewId().ToString(); - var now = TimeProvider.System.GetUtcNow(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", now), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", null), - ("enabled_at", now), - ("disabled_at", null), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); - using var client = CreateBackOfficeClientForIdentity(identity); - - // Act - var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants?HasOverride=true"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); - result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); - result.Tenants.Should().Contain(t => t.Id.Value == tenantId); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideOmittedOnBackOfficeRoute_ShouldReturnMixedSources() - { - // Arrange - one tenant has a manual override, default tenants do not - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var overrideId = FeatureFlagId.NewId().ToString(); - var now = TimeProvider.System.GetUtcNow(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", now), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", null), - ("enabled_at", now), - ("disabled_at", null), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); - using var client = CreateBackOfficeClientForIdentity(identity); - - // Act - var response = await client.GetAsync($"/api/back-office/feature-flags/{flagKey}/tenants"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); - result.Should().NotBeNull(); - result.Tenants.Should().Contain(t => t.Source == "manual_override"); - } -} diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index 98cfb2632..f745fe4a4 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -2,283 +2,21 @@ using System.Net.Http.Json; using Account.Database; using Account.Features.FeatureFlags.Commands; -using Account.Features.FeatureFlags.Domain; using Account.Features.FeatureFlags.Queries; -using Account.Features.Subscriptions.Domain; -using Account.Features.Users.Domain; using FluentAssertions; -using SharedKernel.Domain; using SharedKernel.FeatureFlags; using SharedKernel.Tests; using SharedKernel.Tests.Persistence; using Xunit; -using FeatureFlagRegistry = SharedKernel.FeatureFlags.FeatureFlags; -using FeatureFlagScope = SharedKernel.FeatureFlags.FeatureFlagScope; namespace Account.Tests.FeatureFlags; +// Tests for the owner/user-facing /api/account/feature-flags/* endpoints plus pure unit tests for the +// rollout-bucket math. Back-office (cross-tenant) feature-flag behavior is exercised in the parallel +// FeatureFlagBackOfficeTests.cs under Tests/BackOffice/FeatureFlags/ — that file owns the kill-switch, +// rollout, override, and listing flows that used to live on /internal-api/account/feature-flags/*. public sealed class FeatureFlagTests : EndpointBaseTest { - // Activation tests - - [Fact] - public async Task ActivateFeatureFlag_WhenValid_ShouldSetEnabledAt() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var enabledAt = Connection.ExecuteScalar( - "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - enabledAt.Should().NotBeNullOrEmpty(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); - TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task ActivateFeatureFlag_WhenAlreadyActive_ShouldUpdateEnabledAt() - { - // Arrange - var flagKey = "beta-features"; - var originalEnabledAt = Connection.ExecuteScalar( - "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedEnabledAt = Connection.ExecuteScalar( - "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - updatedEnabledAt.Should().NotBe(originalEnabledAt); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task DeactivateFeatureFlag_WhenActive_ShouldSetDisabledAt() - { - // Arrange - var flagKey = "beta-features"; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var disabledAt = Connection.ExecuteScalar( - "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - var enabledAt = Connection.ExecuteScalar( - "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - disabledAt.Should().NotBeNullOrEmpty(); - enabledAt.Should().NotBeNullOrEmpty("EnabledAt should be preserved on deactivation"); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagDeactivated"); - TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task DeactivateFeatureFlag_WhenAlreadyInactive_ShouldHandleGracefully() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagDeactivated"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task ActivateFeatureFlag_AfterDeactivation_ShouldReactivateFlag() - { - // Arrange - var flagKey = "beta-features"; - await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); - TelemetryEventsCollectorSpy.Reset(); - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var enabledAt = Connection.ExecuteScalar( - "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - var disabledAt = Connection.ExecuteScalar( - "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - enabledAt.Should().NotBeNullOrEmpty(); - disabledAt.Should().BeNull("DisabledAt should be cleared on reactivation"); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagActivated"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - // Tenant override tests (internal API) - - [Fact] - public async Task SetTenantFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow() - { - // Arrange - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rowCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId = tenantId.Value }] - ); - rowCount.Should().Be(1); - var enabledAt = Connection.ExecuteScalar( - "SELECT enabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId = tenantId.Value }] - ); - enabledAt.Should().NotBeNullOrEmpty(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideSet"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task SetTenantFeatureFlagInternal_WhenDisabledWithNoExistingOverride_ShouldCreateDisabledOverrideRow() - { - // Arrange - tenant has no override row (enabled via A/B rollout or default) - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = false }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rowCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId = tenantId.Value }] - ); - rowCount.Should().Be(1); - var disabledAt = Connection.ExecuteScalar( - "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId = tenantId.Value }] - ); - disabledAt.Should().BeNull("newly created disabled override should not have disabled_at set when never activated"); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideRemoved"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task SetTenantFeatureFlagInternal_WhenCalledWithoutAuthContext_ShouldSucceed() - { - // Arrange - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; - - // Act - var response = await AnonymousHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideSet"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - // Remove tenant override tests (internal API) - - [Fact] - public async Task RemoveTenantFeatureFlagOverride_WhenOverrideExists_ShouldDeleteRow() - { - // Arrange - create an override row first - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var overrideId = FeatureFlagId.NewId().ToString(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", TimeProvider.GetUtcNow()), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", null), - ("enabled_at", TimeProvider.GetUtcNow()), - ("disabled_at", null), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rowCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId = tenantId.Value }] - ); - rowCount.Should().Be(0); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagTenantOverrideRemoved"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task RemoveTenantFeatureFlagOverride_WhenNoOverrideExists_ShouldReturnNotFound() - { - // Arrange - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - - // Act - var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override?tenantId={tenantId}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - // Tenant override tests (owner API) [Fact] @@ -350,7 +88,7 @@ public async Task SetTenantFeatureFlagOwner_WhenMember_ShouldReturnForbidden() TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } - // User override tests + // User override tests (owner API) [Fact] public async Task SetUserFeatureFlag_WhenUserConfigurable_ShouldCreateOverrideRow() @@ -387,1067 +125,129 @@ public async Task SetUserFeatureFlag_WhenNotUserScoped_ShouldFailValidation() TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } - // User override tests (internal API) + // Configurable-flag query tests [Fact] - public async Task SetUserFeatureFlagInternal_WhenEnabled_ShouldCreateOverrideRow() + public async Task GetTenantConfigurableFlags_WhenCalled_ShouldReturnConfigurableFlagsWithCurrentOverrideState() { - // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id; - var tenantId = DatabaseSeeder.Tenant1.Id; - var command = new SetUserFeatureFlagInternalCommand { UserId = userId, TenantId = tenantId, Enabled = true }; - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/user-override", command); + var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account/feature-flags/tenant-configurable"); // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Flags.Should().Contain(f => f.FlagKey == "custom-branding" && f.Enabled == false); + } - var rowCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", - [new { flagKey, userId = userId.Value }] - ); - rowCount.Should().Be(1); + [Fact] + public async Task GetUserConfigurableFlags_WhenCalled_ShouldReturnConfigurableUserFlagsWithCurrentOverrideState() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account/feature-flags/user-configurable"); - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagUserOverrideSet"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + // Assert + response.ShouldBeSuccessfulGetRequest(); + var result = await response.DeserializeResponse(); + result.Should().NotBeNull(); + result.Flags.Should().Contain(f => f.FlagKey == "compact-view" && f.Enabled == false); } + // JWT-invalidation tests for owner/user-facing routes (cross-tenant kill-switch invalidation tests + // live in FeatureFlagBackOfficeTests.cs since the trigger is back-office). + [Fact] - public async Task RemoveUserFeatureFlagOverride_WhenOverrideExists_ShouldDeleteRow() + public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() { // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + var flagKey = "custom-branding"; var tenantId = DatabaseSeeder.Tenant1.Id; - var overrideId = FeatureFlagId.NewId().ToString(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", TimeProvider.GetUtcNow()), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", userId), - ("enabled_at", TimeProvider.GetUtcNow()), - ("disabled_at", null), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); + var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; // Act - var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var rowCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", - [new { flagKey, userId }] + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] ); - rowCount.Should().Be(0); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagUserOverrideRemoved"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + updatedVersion.Should().Be(originalVersion + 1); } [Fact] - public async Task RemoveUserFeatureFlagOverride_WhenNoOverrideExists_ShouldReturnNotFound() + public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() { // Arrange var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); var tenantId = DatabaseSeeder.Tenant1.Id; + var originalVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + var command = new SetUserFeatureFlagCommand { Enabled = true }; // Act - var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={tenantId}"); + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/user-override", command); // Assert - response.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedVersion = Connection.ExecuteScalar( + "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] + ); + updatedVersion.Should().Be(originalVersion + 1); } + // A/B rollout bucket math (pure unit tests, no HTTP) + [Fact] - public async Task GetFeatureFlagUsers_WhenNoSearchProvided_ShouldReturnAllUsersPaginated() + public void BucketRange_WhenNormalRange_ShouldMatchCorrectly() { - // Arrange - var flagKey = "compact-view"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); + IsInRolloutBucketRange(50, 40, 60).Should().BeTrue(); + IsInRolloutBucketRange(39, 40, 60).Should().BeFalse(); + IsInRolloutBucketRange(61, 40, 60).Should().BeFalse(); + IsInRolloutBucketRange(40, 40, 60).Should().BeTrue(); + IsInRolloutBucketRange(60, 40, 60).Should().BeTrue(); + } - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().NotBeEmpty(); - result.TotalCount.Should().BeGreaterThan(0); - result.PageSize.Should().Be(25); - result.CurrentPageOffset.Should().Be(0); - result.TotalPages.Should().BeGreaterThan(0); + [Fact] + public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() + { + // Wrap-around within 0-99 range. + IsInRolloutBucketRange(95, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(5, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(50, 90, 10).Should().BeFalse(); + IsInRolloutBucketRange(90, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(10, 90, 10).Should().BeTrue(); + IsInRolloutBucketRange(11, 90, 10).Should().BeFalse(); + IsInRolloutBucketRange(89, 90, 10).Should().BeFalse(); + IsInRolloutBucketRange(0, 90, 10).Should().BeTrue(); } [Fact] - public async Task GetFeatureFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchingUsersWithDefaultSource() + public void RolloutBucket_ShouldBeDeterministic() { // Arrange - var flagKey = "compact-view"; + var sequenceNumber = 42; // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?search=owner@tenant-1"); + var bucket1 = RolloutBucketHasher.ComputeRolloutBucket(sequenceNumber); + var bucket2 = RolloutBucketHasher.ComputeRolloutBucket(sequenceNumber); // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().NotBeEmpty(); - result.Users.Should().OnlyContain(u => u.Source == "default"); + bucket1.Should().Be(bucket2); + bucket1.Should().BeInRange(0, 99); } [Fact] - public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithManualOverrideSource() + public void VanDerCorput_ShouldDistributeEvenly() { // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id; - var overrideId = FeatureFlagId.NewId().ToString(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", TimeProvider.GetUtcNow()), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", userId), - ("enabled_at", TimeProvider.GetUtcNow()), - ("disabled_at", null), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?search=owner@tenant-1"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().NotBeEmpty(); - var userResult = result.Users.Single(u => u.Id.Value == userId); - userResult.IsEnabled.Should().BeTrue(); - userResult.Source.Should().Be("manual_override"); - userResult.Email.Should().NotBe("Unknown"); - userResult.TenantName.Should().NotBe("Unknown"); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenNonUserScopedFlag_ShouldReturnBadRequest() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/sso/users"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // Rollout percentage tests - - [Fact] - public async Task SetFeatureFlagRolloutPercentage_WhenValidPercentage_ShouldUpdateBucketRange() - { - // Arrange - var flagKey = "beta-features"; - var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rolloutBucketStart = Connection.ExecuteScalar( - "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - var rolloutBucketEnd = Connection.ExecuteScalar( - "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - rolloutBucketStart.Should().NotBeNull(); - rolloutBucketEnd.Should().NotBeNull(); - CountBucketsInRange((int)rolloutBucketStart.Value, (int)rolloutBucketEnd.Value).Should().Be(50); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); - TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); - TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.rollout_percentage"].Should().Be("50"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Theory] - [InlineData(1)] - [InlineData(99)] - public async Task SetFeatureFlagRolloutPercentage_WhenSetToNPercent_ShouldIncludeExactlyNBuckets(int percentage) - { - // Arrange - var flagKey = "beta-features"; - var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = percentage }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rolloutBucketStart = Connection.ExecuteScalar( - "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - var rolloutBucketEnd = Connection.ExecuteScalar( - "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - rolloutBucketStart.Should().NotBeNull(); - rolloutBucketEnd.Should().NotBeNull(); - CountBucketsInRange((int)rolloutBucketStart.Value, (int)rolloutBucketEnd.Value).Should().Be(percentage); - } - - [Fact] - public async Task SetFeatureFlagRolloutPercentage_WhenInvalidPercentage_ShouldFailValidation() - { - // Arrange - var flagKey = "beta-features"; - var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 101 }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - - TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task SetFeatureFlagRolloutPercentage_WhenNonAbTestEligible_ShouldFailValidation() - { - // Arrange - var flagKey = "sso"; - var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - - TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task SetFeatureFlagRolloutPercentage_WhenZeroPercent_ShouldClearBucketRange() - { - // Arrange - var flagKey = "beta-features"; - var setCommand = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; - await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", setCommand); - TelemetryEventsCollectorSpy.Reset(); - var clearCommand = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 0 }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", clearCommand); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rolloutBucketStart = Connection.ExecuteScalar( - "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - var rolloutBucketEnd = Connection.ExecuteScalar( - "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - rolloutBucketStart.Should().BeNull(); - rolloutBucketEnd.Should().BeNull(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task SetFeatureFlagRolloutPercentage_WhenHundredPercent_ShouldSetFullRange() - { - // Arrange - var flagKey = "beta-features"; - var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 100 }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var rolloutBucketStart = Connection.ExecuteScalar( - "SELECT bucket_start FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - var rolloutBucketEnd = Connection.ExecuteScalar( - "SELECT bucket_end FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - rolloutBucketStart.Should().Be(0); - rolloutBucketEnd.Should().Be(99); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("FeatureFlagRolloutPercentageUpdated"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - // Tenant configurable flags query tests - - [Fact] - public async Task GetTenantConfigurableFlags_WhenCalled_ShouldReturnConfigurableFlagsWithCurrentOverrideState() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account/feature-flags/tenant-configurable"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Flags.Should().Contain(f => f.FlagKey == "custom-branding" && f.Enabled == false); - } - - [Fact] - public async Task GetUserConfigurableFlags_WhenCalled_ShouldReturnConfigurableUserFlagsWithCurrentOverrideState() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account/feature-flags/user-configurable"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Flags.Should().Contain(f => f.FlagKey == "compact-view" && f.Enabled == false); - } - - // Flag tenants query tests - - [Fact] - public async Task GetFeatureFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTenantsWithDefaultSource() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().NotBeEmpty(); - result.Tenants.Should().AllSatisfy(t => - { - t.Source.Should().Be("default"); - t.IsEnabled.Should().BeFalse(); - } - ); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManualOverrideSource() - { - // Arrange - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var overrideId = FeatureFlagId.NewId().ToString(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", TimeProvider.GetUtcNow()), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", null), - ("enabled_at", TimeProvider.GetUtcNow()), - ("disabled_at", null), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); - tenantResult.IsEnabled.Should().BeTrue(); - tenantResult.Source.Should().Be("manual_override"); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenFlagHasRollout_ShouldReturnAbRolloutSource() - { - // Arrange - var flagKey = "beta-features"; - var baseRowId = Connection.ExecuteScalar( - "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - Connection.Update("feature_flags", "id", baseRowId, [ - ("bucket_start", 0), - ("bucket_end", 99) - ] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().AllSatisfy(t => - { - t.Source.Should().Be("ab_rollout"); - t.IsEnabled.Should().BeTrue(); - } - ); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRolloutActive_ShouldReturnManualOverrideDisabled() - { - // Arrange - set up A/B rollout at 100% so all tenants are enabled - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var baseRowId = Connection.ExecuteScalar( - "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - Connection.Update("feature_flags", "id", baseRowId, [ - ("bucket_start", 0), - ("bucket_end", 99) - ] - ); - - // Create a disabled override for the tenant (simulating admin toggling OFF) - var overrideId = FeatureFlagId.NewId().ToString(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", TimeProvider.GetUtcNow()), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", null), - ("enabled_at", null), - ("disabled_at", TimeProvider.GetUtcNow()), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - - // Assert - manual override should take precedence over A/B rollout - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); - tenantResult.IsEnabled.Should().BeFalse(); - tenantResult.Source.Should().Be("manual_override"); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenNonExistentFlag_ShouldReturnBadRequest() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/non-existent/tenants"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenSystemScopedFlag_ShouldReturnBadRequest() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags/google-oauth/tenants"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // Pagination + filtering tests - - [Fact] - public async Task GetFeatureFlagTenants_WhenSearchMatchesOwnerEmail_ShouldReturnMatchingTenants() - { - // Arrange - var flagKey = "sso"; - - // Act - DatabaseSeeder.Tenant1Owner email is "owner@tenant-1.com", so search by "tenant-1" hits owner email - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Search=tenant-1"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().NotBeEmpty(); - result.Tenants.Should().OnlyContain(t => t.Owner!.Email.Contains("tenant-1")); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenSearchHasNoMatches_ShouldReturnEmpty() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Search=does-not-exist-anywhere"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().BeEmpty(); - result.TotalCount.Should().Be(0); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenPlansFilterMatches_ShouldReturnOnlyTenantsOnSelectedPlans() - { - // Arrange - DatabaseSeeder.Tenant1 is on the Basis plan - var flagKey = "sso"; - - // Act - var matchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Plans=Basis"); - var noMatchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?Plans=Premium"); - - // Assert - matchResponse.ShouldBeSuccessfulGetRequest(); - noMatchResponse.ShouldBeSuccessfulGetRequest(); - var matchResult = await matchResponse.DeserializeResponse(); - var noMatchResult = await noMatchResponse.DeserializeResponse(); - matchResult.Should().NotBeNull(); - noMatchResult.Should().NotBeNull(); - matchResult.Tenants.Should().OnlyContain(t => t.Plan == SubscriptionPlan.Basis); - matchResult.TotalCount.Should().BeGreaterThan(0); - noMatchResult.Tenants.Should().BeEmpty(); - noMatchResult.TotalCount.Should().Be(0); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenStateEnabledAndTenantHasManualOverride_ShouldReturnOnlyEnabledTenants() - { - // Arrange - tenant-1 gets a manual enable for `sso`; all other tenants remain disabled by default - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Enabled"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.IsEnabled); - result.Tenants.Should().Contain(t => t.Id.Value == tenantId); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenStateDisabledAndTenantIsEnabledViaOverride_ShouldExcludeTenant() - { - // Arrange - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Disabled"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().NotContain(t => t.Id.Value == tenantId); - result.Tenants.Where(t => t.IsEnabled).Should().BeEmpty(); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenStateOmitted_ShouldNotFilterByState() - { - // Arrange - tenant-1 gets enabled, then ask without State (omitted = no filter) - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, true); - - // Act - var omittedResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - var enabledResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?State=Enabled"); - - // Assert - omittedResponse.ShouldBeSuccessfulGetRequest(); - enabledResponse.ShouldBeSuccessfulGetRequest(); - var omitted = await omittedResponse.DeserializeResponse(); - var enabled = await enabledResponse.DeserializeResponse(); - omitted.Should().NotBeNull(); - enabled.Should().NotBeNull(); - omitted.TotalCount.Should().BeGreaterThanOrEqualTo(enabled.TotalCount); - omitted.Tenants.Should().Contain(t => t.Id.Value == tenantId); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenPaginated_ShouldReturnCorrectPagingMetadata() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?PageSize=1&PageOffset=0"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.PageSize.Should().Be(1); - result.CurrentPageOffset.Should().Be(0); - result.Tenants.Length.Should().BeLessOrEqualTo(1); - result.TotalPages.Should().Be(result.TotalCount); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenSortedByName_ShouldReturnTenantsInAscendingNameOrder() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().BeInAscendingOrder(t => t.Name); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenSearchMatchesEmail_ShouldReturnOnlyMatchingUsers() - { - // Arrange - var flagKey = "compact-view"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?Search=owner@tenant-1"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().NotBeEmpty(); - result.Users.Should().OnlyContain(u => u.Email.Contains("owner@tenant-1")); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenRolesFilterApplied_ShouldReturnOnlyUsersWithSelectedRoles() - { - // Arrange - var flagKey = "compact-view"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?Roles=Owner"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().NotBeEmpty(); - result.Users.Should().OnlyContain(u => u.Role == UserRole.Owner); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenStateEnabledAndUserHasManualOverride_ShouldReturnOnlyEnabledUsers() - { - // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertUserOverride(flagKey, tenantId, userId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?State=Enabled"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().OnlyContain(u => u.IsEnabled); - result.Users.Should().Contain(u => u.Id.Value == userId); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenStateDisabled_ShouldReturnOnlyDisabledUsers() - { - // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertUserOverride(flagKey, tenantId, userId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?State=Disabled"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().OnlyContain(u => !u.IsEnabled); - result.Users.Should().NotContain(u => u.Id.Value == userId); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenPaginated_ShouldReturnCorrectPagingMetadata() - { - // Arrange - var flagKey = "compact-view"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?PageSize=1&PageOffset=0"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.PageSize.Should().Be(1); - result.CurrentPageOffset.Should().Be(0); - result.Users.Length.Should().BeLessOrEqualTo(1); - result.TotalPages.Should().Be(result.TotalCount); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenPageOffsetExceedsTotalPages_ShouldReturnBadRequest() - { - // Arrange - var flagKey = "compact-view"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?PageOffset=100"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // HasOverride filter tests - - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideOmitted_ShouldNotFilterByOverride() - { - // Arrange - seed an override so the dataset contains both override and non-override rows (Source: "manual_override" + "default") - var flagKey = "sso"; - InsertTenantOverride(flagKey, DatabaseSeeder.Tenant1.Id, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().Contain(t => t.Source == "manual_override"); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideTrue_ShouldReturnOnlyTenantsWithManualOverride() - { - // Arrange - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); - result.Tenants.Should().Contain(t => t.Id.Value == tenantId); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndStateDisabled_ShouldReturnDisabledOverrides() - { - // Arrange - tenant-1 has a disabling manual override; no other tenants have manual overrides - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - InsertTenantOverride(flagKey, tenantId, false); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true&State=Disabled"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override" && !t.IsEnabled); - result.Tenants.Should().Contain(t => t.Id.Value == tenantId); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndNoTenantHasOverride_ShouldReturnEmpty() - { - // Arrange - var flagKey = "sso"; - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().BeEmpty(); - result.TotalCount.Should().Be(0); - } - - [Fact] - public async Task GetFeatureFlagTenants_WhenHasOverrideTrueOnAbRolloutFlag_ShouldExcludeAbRolloutAndDefaultRows() - { - // Arrange - beta-features is A/B-eligible. Configure a 100% rollout so every tenant evaluates as "ab_rollout", - // then add a manual override for tenant-1. HasOverride=true should keep only tenant-1. - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var baseRowId = Connection.ExecuteScalar( - "SELECT id FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] - ); - Connection.Update("feature_flags", "id", baseRowId, [ - ("bucket_start", 0), - ("bucket_end", 99) - ] - ); - InsertTenantOverride(flagKey, tenantId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/tenants?HasOverride=true"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); - result.Tenants.Should().Contain(t => t.Id.Value == tenantId); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenHasOverrideOmitted_ShouldNotFilterByOverride() - { - // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().Contain(u => u.Source == "manual_override"); - result.Users.Should().Contain(u => u.Source == "default"); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenHasOverrideTrue_ShouldReturnOnlyUsersWithManualOverride() - { - // Arrange - var flagKey = "compact-view"; - var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, userId, true); - - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?HasOverride=true"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Users.Should().OnlyContain(u => u.Source == "manual_override"); - result.Users.Should().Contain(u => u.Id.Value == userId); - } - - [Fact] - public async Task GetFeatureFlagUsers_WhenHasOverrideTrueAndRoleFiltered_ShouldReturnOnlyMatchingUsersWithOverride() - { - // Arrange - only the owner gets the override; member gets none - var flagKey = "compact-view"; - var ownerId = DatabaseSeeder.Tenant1Owner.Id.ToString(); - InsertUserOverride(flagKey, DatabaseSeeder.Tenant1.Id, ownerId, true); - - // Act - var matchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?HasOverride=true&Roles=Owner"); - var noMatchResponse = await AuthenticatedOwnerHttpClient.GetAsync($"/internal-api/account/feature-flags/{flagKey}/users?HasOverride=true&Roles=Member"); - - // Assert - matchResponse.ShouldBeSuccessfulGetRequest(); - noMatchResponse.ShouldBeSuccessfulGetRequest(); - var matchResult = await matchResponse.DeserializeResponse(); - var noMatchResult = await noMatchResponse.DeserializeResponse(); - matchResult.Should().NotBeNull(); - noMatchResult.Should().NotBeNull(); - matchResult.Users.Should().OnlyContain(u => u.Source == "manual_override" && u.Role == UserRole.Owner); - matchResult.Users.Should().Contain(u => u.Id.Value == ownerId); - noMatchResult.Users.Should().BeEmpty(); - } - - private void InsertTenantOverride(string flagKey, TenantId tenantId, bool enabled) - { - var overrideId = FeatureFlagId.NewId().ToString(); - var now = TimeProvider.System.GetUtcNow(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", now), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", null), - ("enabled_at", enabled ? now : null), - ("disabled_at", enabled ? null : now), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - } - - private void InsertUserOverride(string flagKey, TenantId tenantId, string userId, bool enabled) - { - var overrideId = FeatureFlagId.NewId().ToString(); - var now = TimeProvider.System.GetUtcNow(); - Connection.Insert("feature_flags", [ - ("id", overrideId), - ("created_at", now), - ("modified_at", null), - ("flag_key", flagKey), - ("tenant_id", tenantId.Value), - ("user_id", userId), - ("enabled_at", enabled ? now : null), - ("disabled_at", enabled ? null : now), - ("bucket_start", null), - ("bucket_end", null), - ("configurable_by_tenant", false), - ("configurable_by_user", false), - ("source", "Manual") - ] - ); - } - - // Query tests - - [Fact] - public async Task GetFeatureFlags_WhenCalled_ShouldReturnAllFlagsWithDatabaseState() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/feature-flags"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Flags.Should().HaveCount(FeatureFlagRegistry.GetAll().Length); - - var googleOauth = result.Flags.Single(f => f.Key == "google-oauth"); - googleOauth.Scope.Should().Be(FeatureFlagScope.System); - googleOauth.EnabledAt.Should().BeNull(); - - var subscriptions = result.Flags.Single(f => f.Key == "subscriptions"); - subscriptions.Scope.Should().Be(FeatureFlagScope.System); - subscriptions.EnabledAt.Should().BeNull(); - - var betaFeatures = result.Flags.Single(f => f.Key == "beta-features"); - betaFeatures.Scope.Should().Be(FeatureFlagScope.Tenant); - betaFeatures.IsAbTestEligible.Should().BeTrue(); - betaFeatures.IsActive.Should().BeTrue(); - betaFeatures.EnabledAt.Should().NotBeNull(); - - var sso = result.Flags.Single(f => f.Key == "sso"); - sso.IsActive.Should().BeFalse(); - sso.EnabledAt.Should().BeNull(); - - var customBranding = result.Flags.Single(f => f.Key == "custom-branding"); - customBranding.ConfigurableByTenant.Should().BeTrue(); - customBranding.IsActive.Should().BeTrue(); - - var compactView = result.Flags.Single(f => f.Key == "compact-view"); - compactView.ConfigurableByUser.Should().BeTrue(); - compactView.IsActive.Should().BeTrue(); - } - - // A/B consistency tests - - [Fact] - public void BucketRange_WhenNormalRange_ShouldMatchCorrectly() - { - // Arrange & Act & Assert - IsInRolloutBucketRange(50, 40, 60).Should().BeTrue(); - IsInRolloutBucketRange(39, 40, 60).Should().BeFalse(); - IsInRolloutBucketRange(61, 40, 60).Should().BeFalse(); - IsInRolloutBucketRange(40, 40, 60).Should().BeTrue(); - IsInRolloutBucketRange(60, 40, 60).Should().BeTrue(); - } - - [Fact] - public void BucketRange_WhenWrapAround_ShouldMatchCorrectly() - { - // Arrange & Act & Assert (wrap-around within 0-99 range) - IsInRolloutBucketRange(95, 90, 10).Should().BeTrue(); - IsInRolloutBucketRange(5, 90, 10).Should().BeTrue(); - IsInRolloutBucketRange(50, 90, 10).Should().BeFalse(); - IsInRolloutBucketRange(90, 90, 10).Should().BeTrue(); - IsInRolloutBucketRange(10, 90, 10).Should().BeTrue(); - IsInRolloutBucketRange(11, 90, 10).Should().BeFalse(); - IsInRolloutBucketRange(89, 90, 10).Should().BeFalse(); - IsInRolloutBucketRange(0, 90, 10).Should().BeTrue(); - } - - [Fact] - public void RolloutBucket_ShouldBeDeterministic() - { - // Arrange - var sequenceNumber = 42; - - // Act - var bucket1 = RolloutBucketHasher.ComputeRolloutBucket(sequenceNumber); - var bucket2 = RolloutBucketHasher.ComputeRolloutBucket(sequenceNumber); - - // Assert - bucket1.Should().Be(bucket2); - bucket1.Should().BeInRange(0, 99); - } - - [Fact] - public void VanDerCorput_ShouldDistributeEvenly() - { - // Arrange - var bucketCounts = new int[100]; + var bucketCounts = new int[100]; // Act for (var i = 0; i < 1000; i++) @@ -1464,156 +264,8 @@ public void VanDerCorput_ShouldDistributeEvenly() } } - // JWT invalidation tests - - [Fact] - public async Task ActivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() - { - // Arrange - var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/activate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); - } - - [Fact] - public async Task DeactivateFeatureFlag_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() - { - // Arrange - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsync($"/internal-api/account/feature-flags/{flagKey}/deactivate", null); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); - } - - [Fact] - public async Task SetTenantFeatureFlagInternal_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() - { - // Arrange - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/tenant-override", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); - } - - [Fact] - public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() - { - // Arrange - var flagKey = "custom-branding"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); - } - - [Fact] - public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() - { - // Arrange - var flagKey = "compact-view"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - var command = new SetUserFeatureFlagCommand { Enabled = true }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/user-override", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); - } - - [Fact] - public async Task SetFeatureFlagRolloutPercentage_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() - { - // Arrange - var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; - - // Act - var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/internal-api/account/feature-flags/{flagKey}/rollout-percentage", command); - - // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); - } - private static bool IsInRolloutBucketRange(int bucket, int rolloutBucketStart, int rolloutBucketEnd) { return RolloutBucketHasher.IsInRolloutBucketRange(bucket, rolloutBucketStart, rolloutBucketEnd); } - - private static int CountBucketsInRange(int rolloutBucketStart, int rolloutBucketEnd) - { - if (rolloutBucketStart <= rolloutBucketEnd) - { - return rolloutBucketEnd - rolloutBucketStart + 1; - } - - return 100 - rolloutBucketStart + rolloutBucketEnd + 1; - } } diff --git a/application/account/Tests/Tenants/GetTenantsTests.cs b/application/account/Tests/Tenants/GetTenantsTests.cs deleted file mode 100644 index b01bf7199..000000000 --- a/application/account/Tests/Tenants/GetTenantsTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Account.Database; -using Account.Features.Tenants.Queries; -using FluentAssertions; -using SharedKernel.Tests; -using Xunit; - -namespace Account.Tests.Tenants; - -public sealed class GetTenantsTests : EndpointBaseTest -{ - [Fact] - public async Task GetTenants_WhenCalled_ShouldReturnAllTenants() - { - // Act - var response = await AuthenticatedOwnerHttpClient.GetAsync("/internal-api/account/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().NotBeEmpty(); - result.Tenants.Should().Contain(t => t.Id == DatabaseSeeder.Tenant1.Id); - } - - [Fact] - public async Task GetTenants_WhenCalledWithoutAuth_ShouldSucceed() - { - // Act - var response = await AnonymousHttpClient.GetAsync("/internal-api/account/tenants"); - - // Assert - response.ShouldBeSuccessfulGetRequest(); - var result = await response.DeserializeResponse(); - result.Should().NotBeNull(); - result.Tenants.Should().NotBeEmpty(); - } -} From cdcff94ded2092afad4145b9697d5d97d42f06de Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 17:27:26 +0200 Subject: [PATCH 055/155] Add orphan delete endpoint and orphanedAt DTO fields for feature flags --- .../Api/BackOffice/FeatureFlagEndpoints.cs | 6 +- .../Commands/DeleteFeatureFlag.cs | 43 ++++++++ .../Domain/FeatureFlagRepository.cs | 12 ++ .../FeatureFlags/Queries/GetFeatureFlags.cs | 8 +- .../account/Core/Features/TelemetryEvents.cs | 3 + .../FeatureFlagBackOfficeTests.cs | 104 ++++++++++++++++++ 6 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 application/account/Core/Features/FeatureFlags/Commands/DeleteFeatureFlag.cs diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 9b37dbe8e..2f024933f 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -32,8 +32,6 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(query with { FlagKey = flagKey }) ).Produces(); - // Kill-switch mutations: reserved for the configured admin group. Back-office is not admin-only; - // tightening these two endpoints prevents non-admin staff from flipping a flag fleet-wide. group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); @@ -42,6 +40,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + group.MapDelete("/{flagKey}", async Task (string flagKey, IMediator mediator) + => await mediator.Send(new DeleteFeatureFlagCommand(flagKey)) + ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) ).DisableAntiforgery(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/DeleteFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/DeleteFeatureFlag.cs new file mode 100644 index 000000000..9437a7da8 --- /dev/null +++ b/application/account/Core/Features/FeatureFlags/Commands/DeleteFeatureFlag.cs @@ -0,0 +1,43 @@ +using Account.Features.FeatureFlags.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Telemetry; + +namespace Account.Features.FeatureFlags.Commands; + +[PublicAPI] +public sealed record DeleteFeatureFlagCommand(string FlagKey) : ICommand, IRequest; + +public sealed class DeleteFeatureFlagValidator : AbstractValidator +{ + public DeleteFeatureFlagValidator() + { + RuleFor(x => x.FlagKey).NotEmpty().WithMessage("Feature flag key must not be empty."); + } +} + +public sealed class DeleteFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(DeleteFeatureFlagCommand command, CancellationToken cancellationToken) + { + var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, null, null, cancellationToken); + if (baseRow is null) return Result.NotFound($"Feature flag with key '{command.FlagKey}' not found."); + + // Only orphaned flags can be hard-deleted. Live flags must instead be removed from FeatureFlags.cs + // and let the reconciler mark them orphaned on the next deploy; this preserves the audit trail and + // ensures tenants/users keep the behavior they had at the moment the flag was removed from code. + if (baseRow.OrphanedAt is null) return Result.BadRequest($"Feature flag '{command.FlagKey}' is not orphaned - only flags removed from the C# definitions can be deleted."); + + var rowsToRemove = await featureFlagRepository.GetRowsByFlagKeyUnfilteredAsync(command.FlagKey, cancellationToken); + foreach (var row in rowsToRemove) + { + featureFlagRepository.Remove(row); + } + + events.CollectEvent(new FeatureFlagDeleted(command.FlagKey)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index cbeae9b7b..c64bce92b 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -29,6 +29,13 @@ public interface IFeatureFlagRepository : ICrudRepository Task GetAllRowsUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Returns every row (base + tenant + user overrides) for a single flag_key across all tenants and + /// users. Used by the back-office hard-delete to cascade-remove an orphaned flag. MUST only be called + /// from admin paths — never from a tenant-scoped request. + /// + Task GetRowsByFlagKeyUnfilteredAsync(string flagKey, CancellationToken cancellationToken); } internal sealed class FeatureFlagRepository(AccountDbContext accountDbContext) @@ -93,4 +100,9 @@ public async Task GetAllRowsUnfilteredAsync(CancellationToken can { return await DbSet.ToArrayAsync(cancellationToken); } + + public async Task GetRowsByFlagKeyUnfilteredAsync(string flagKey, CancellationToken cancellationToken) + { + return await DbSet.Where(f => f.FlagKey == flagKey).ToArrayAsync(cancellationToken); + } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs index 54d8565c8..0453b8be2 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlags.cs @@ -28,7 +28,9 @@ public sealed record FeatureFlagInfo( int? RolloutBucketStart, int? RolloutBucketEnd, int? RolloutPercentage, - bool IsActive + bool IsActive, + bool IsKillSwitchEnabled, + DateTimeOffset? OrphanedAt ); public sealed class GetFeatureFlagsHandler(IFeatureFlagRepository featureFlagRepository, IConfiguration configuration) @@ -48,7 +50,7 @@ public async Task> Handle(GetFeatureFlagsQuery r return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, definition.RequiredPlan?.ToString(), - null, null, null, null, null, null, isSystemFeatureFlagActive + null, null, null, null, null, null, isSystemFeatureFlagActive, definition.IsKillSwitchEnabled, null ); } @@ -65,7 +67,7 @@ public async Task> Handle(GetFeatureFlagsQuery r return new FeatureFlagInfo( definition.Key, definition.Scope, definition.AdminLevel, definition.Description, definition.IsAbTestEligible, definition.ConfigurableByTenant, definition.ConfigurableByUser, definition.RequiredPlan?.ToString(), - createdAt, enabledAt, disabledAt, rolloutBucketStart, rolloutBucketEnd, rolloutPercentage, isActive + createdAt, enabledAt, disabledAt, rolloutBucketStart, rolloutBucketEnd, rolloutPercentage, isActive, definition.IsKillSwitchEnabled, baseRow?.OrphanedAt ); } ).ToArray(); diff --git a/application/account/Core/Features/TelemetryEvents.cs b/application/account/Core/Features/TelemetryEvents.cs index c83ee8733..a1ee20991 100644 --- a/application/account/Core/Features/TelemetryEvents.cs +++ b/application/account/Core/Features/TelemetryEvents.cs @@ -70,6 +70,9 @@ public sealed class FeatureFlagActivated(string flagKey) public sealed class FeatureFlagDeactivated(string flagKey) : TelemetryEvent(("flag_key", flagKey)); +public sealed class FeatureFlagDeleted(string flagKey) + : TelemetryEvent(("flag_key", flagKey)); + public sealed class FeatureFlagRolloutPercentageUpdated(string flagKey, int rolloutPercentage) : TelemetryEvent(("flag_key", flagKey), ("rollout_percentage", rolloutPercentage)); diff --git a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs index 50e5bfe50..966b6b9c1 100644 --- a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs +++ b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs @@ -1218,6 +1218,110 @@ public async Task SetRolloutPercentage_WhenCalled_ShouldIncrementAllTenantsFeatu updatedVersion.Should().Be(originalVersion + 1); } + // Delete feature flag (AdminPolicyName, orphan-only) + + [Fact] + public async Task DeleteFeatureFlag_WhenOrphanedAndAdmin_ShouldRemoveAllRowsAndEmitTelemetry() + { + // Arrange - simulate a flag that was removed from the C# definitions and marked orphaned by the + // reconciler. The base row plus a tenant override and a user override must all be cascade-removed. + var flagKey = "removed-feature"; + var tenantId = DatabaseSeeder.Tenant1.Id; + var userId = DatabaseSeeder.Tenant1Owner.Id.ToString(); + InsertOrphanedBaseRow(flagKey); + InsertTenantOverride(flagKey, tenantId, true); + InsertUserOverride(flagKey, tenantId, userId, true); + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + var remaining = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey", [new { flagKey }] + ); + remaining.Should().Be(0, "the orphan hard-delete must cascade across base, tenant override, and user override rows"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagDeleted"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.flag_key"].Should().Be(flagKey); + } + + [Fact] + public async Task DeleteFeatureFlag_WhenNotOrphaned_ShouldReturnBadRequest() + { + // Arrange - sso is a live flag with OrphanedAt = NULL; hard-delete must be rejected. + var flagKey = "sso"; + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var baseRowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id IS NULL AND user_id IS NULL", [new { flagKey }] + ); + baseRowCount.Should().Be(1, "a non-orphaned flag must not be deleted"); + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteFeatureFlag_WhenFlagDoesNotExist_ShouldReturnNotFound() + { + // Arrange + using var client = CreateAdminBackOfficeClient(); + + // Act + var response = await client.DeleteAsync("/api/back-office/feature-flags/does-not-exist"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteFeatureFlag_WhenNonAdminBackOfficeIdentity_ShouldReturnForbidden() + { + // Arrange + var flagKey = "removed-feature"; + InsertOrphanedBaseRow(flagKey); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}"); + + // Assert - the delete route is gated by AdminPolicyName, so a regular back-office identity without + // the admin group claim must be rejected. + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var baseRowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey", [new { flagKey }] + ); + baseRowCount.Should().Be(1, "the orphaned row must remain when a non-admin caller is rejected"); + } + + private void InsertOrphanedBaseRow(string flagKey) + { + var rowId = FeatureFlagId.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + Connection.Insert("feature_flags", [ + ("id", rowId), + ("created_at", now), + ("modified_at", null), + ("flag_key", flagKey), + ("tenant_id", null), + ("user_id", null), + ("enabled_at", now), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual"), + ("orphaned_at", now) + ] + ); + } + private void InsertTenantOverride(string flagKey, TenantId tenantId, bool enabled) { var overrideId = FeatureFlagId.NewId().ToString(); From 69a1d6e1984a2ecd8714ad082507d7f1ac1dc4f2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 17:27:35 +0200 Subject: [PATCH 056/155] Add orphan badge, manual delete dialog, empty state, and hide global toggle on locked flags --- .../routes/feature-flags/$flagKey.tsx | 44 +++++++++- .../-components/DeleteFeatureFlagDialog.tsx | 83 +++++++++++++++++++ .../-components/FeatureFlagInfoSection.tsx | 30 ++++++- .../-components/OrphanedFeatureFlagBadge.tsx | 33 ++++++++ .../-components/TenantOverridesSection.tsx | 23 ++--- .../-components/UserOverridesSection.tsx | 23 ++--- .../routes/feature-flags/-components/types.ts | 2 + .../shared/translations/locale/da-DK.po | 59 ++++++++----- .../shared/translations/locale/en-US.po | 59 ++++++++----- .../infrastructure/featureFlags/registry.ts | 7 ++ 10 files changed, 279 insertions(+), 84 deletions(-) create mode 100644 application/account/BackOffice/routes/feature-flags/-components/DeleteFeatureFlagDialog.tsx create mode 100644 application/account/BackOffice/routes/feature-flags/-components/OrphanedFeatureFlagBadge.tsx diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index cccc17677..243b85846 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -1,9 +1,12 @@ import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Button } from "@repo/ui/components/Button"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { ArrowLeftIcon } from "lucide-react"; +import { ArrowLeftIcon, Trash2Icon } from "lucide-react"; +import { useState } from "react"; import { z } from "zod"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; @@ -11,8 +14,10 @@ import { api, FeatureFlagAudienceState, SubscriptionPlan, UserRole } from "@/sha import type { GetFeatureFlagsResponse } from "./-components/types"; +import { DeleteFeatureFlagDialog } from "./-components/DeleteFeatureFlagDialog"; import { FeatureFlagInfoSection } from "./-components/FeatureFlagInfoSection"; import { getFeatureFlagDescription, getFeatureFlagName } from "./-components/flagLabels"; +import { OrphanedFeatureFlagBadge } from "./-components/OrphanedFeatureFlagBadge"; import { PlanFeatureFlagInfoSection, PlanFeatureFlagTenantsSection } from "./-components/PlanFeatureFlagSections"; import { ScopeIcon } from "./-components/ScopeIcon"; import { ALL_STATE_FILTER } from "./-components/stateFilter"; @@ -59,6 +64,8 @@ export default function FeatureFlagDetailPage() { usersPageOffset } = Route.useSearch(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const { data: featureFlagsData, isLoading: isLoadingFeatureFlags } = api.useQuery( "get", "/api/back-office/feature-flags" @@ -68,6 +75,7 @@ export default function FeatureFlagDetailPage() { }; const featureFlag = featureFlagsData?.flags?.find((f) => f.key === flagKey); + const isOrphaned = featureFlag?.orphanedAt != null; const isPlanFeatureFlag = featureFlag?.requiredPlan != null; const isLoading = isLoadingFeatureFlags; @@ -83,12 +91,13 @@ export default function FeatureFlagDetailPage() { maxWidth="64rem" browserTitle={featureFlagName} title={ -
+
{featureFlag && } {featureFlagName} + {featureFlag?.orphanedAt && }
} subtitle={featureFlag ? description : undefined} @@ -100,7 +109,28 @@ export default function FeatureFlagDetailPage() { {isPlanFeatureFlag ? ( ) : ( - + + )} + {isOrphaned && ( +
+

+ This flag no longer exists in code +

+

+ + Account and user state is preserved but no longer evaluated. Delete the flag to remove the global + row and all account and user overrides permanently. + +

+ +
)} {featureFlag.scope === "Tenant" && !isPlanFeatureFlag && ( + {featureFlag && isOrphaned && ( + + )} ); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/DeleteFeatureFlagDialog.tsx b/application/account/BackOffice/routes/feature-flags/-components/DeleteFeatureFlagDialog.tsx new file mode 100644 index 000000000..fa34906ef --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/DeleteFeatureFlagDialog.tsx @@ -0,0 +1,83 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle +} from "@repo/ui/components/AlertDialog"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { Trash2Icon } from "lucide-react"; +import { toast } from "sonner"; + +import { api } from "@/shared/lib/api/client"; + +interface DeleteFeatureFlagDialogProps { + flagKey: string; + flagName: string; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +export function DeleteFeatureFlagDialog({ + flagKey, + flagName, + isOpen, + onOpenChange +}: Readonly) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const deleteMutation = api.useMutation("delete", "/api/back-office/feature-flags/{flagKey}", { + onSuccess: async () => { + toast.success(t`Feature flag deleted: ${flagName}`); + await queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === "get" && + query.queryKey[1] === "/api/back-office/feature-flags" + }); + onOpenChange(false); + navigate({ to: "/feature-flags" }); + } + }); + + const handleDelete = () => { + deleteMutation.mutate({ params: { path: { flagKey } } }); + }; + + return ( + + + + + + + + Delete feature flag + + + + Permanently delete {flagName} ({flagKey}) and all account and user overrides. This cannot be + undone. + + + + + + Cancel + + + Delete flag and all overrides + + + + + ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx index 72d29512a..d2022d8e2 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx @@ -15,7 +15,17 @@ import type { FeatureFlagInfo } from "./types"; import { getFeatureFlagName } from "./flagLabels"; import { formatRolloutBucketRange } from "./rolloutBucket"; -export function FeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: FeatureFlagInfo }>) { +interface FeatureFlagInfoSectionProps { + featureFlag: FeatureFlagInfo; + isKillSwitchEnabled: boolean; + orphanedAt: string | null; +} + +export function FeatureFlagInfoSection({ + featureFlag, + isKillSwitchEnabled, + orphanedAt +}: Readonly) { const activateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/activate"); const deactivateMutation = api.useMutation("put", "/api/back-office/feature-flags/{flagKey}/deactivate"); const isPending = activateMutation.isPending || deactivateMutation.isPending; @@ -32,11 +42,13 @@ export function FeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: ); }; + const showToggle = isKillSwitchEnabled && orphanedAt === null; + return (
- {featureFlag.isAbTestEligible ? ( + {showToggle && featureFlag.isAbTestEligible ? (
Rollout % @@ -54,7 +66,7 @@ export function FeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: />
- ) : ( + ) : showToggle ? (
{featureFlag.isActive ? t`Active` : t`Inactive`} @@ -66,8 +78,20 @@ export function FeatureFlagInfoSection({ featureFlag }: Readonly<{ featureFlag: aria-label={t`Toggle ${getFeatureFlagName(featureFlag.key)}`} />
+ ) : ( + + {featureFlag.isActive ? t`Active` : t`Inactive`} + )}
+ {!isKillSwitchEnabled && orphanedAt === null && ( +

+ + This flag is platform-managed. Plan-based flags update with the account's subscription. Stable features are + not togglable. + +

+ )}
); } diff --git a/application/account/BackOffice/routes/feature-flags/-components/OrphanedFeatureFlagBadge.tsx b/application/account/BackOffice/routes/feature-flags/-components/OrphanedFeatureFlagBadge.tsx new file mode 100644 index 000000000..a4c9c2d72 --- /dev/null +++ b/application/account/BackOffice/routes/feature-flags/-components/OrphanedFeatureFlagBadge.tsx @@ -0,0 +1,33 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; + +interface OrphanedFeatureFlagBadgeProps { + orphanedAt: string; +} + +export function OrphanedFeatureFlagBadge({ orphanedAt }: Readonly) { + const formatted = new Intl.DateTimeFormat(navigator.language, { + year: "numeric", + month: "long", + day: "numeric" + }).format(new Date(orphanedAt)); + + return ( + + + + Removed from definitions + + + + + This flag no longer exists in FeatureFlags.cs as of {formatted}. Account and user state is preserved but no + longer evaluated by new code paths or telemetry. Use the delete action to remove the global row and all + overrides. + + + + ); +} diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx index c4205c788..29a8e4622 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverridesSection.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { TablePagination } from "@repo/ui/components/TablePagination"; import { keepPreviousData } from "@tanstack/react-query"; @@ -10,12 +10,12 @@ import { useCallback } from "react"; import type { SubscriptionPlan } from "@/shared/lib/api/client"; -import { api, FeatureFlagAudienceState } from "@/shared/lib/api/client"; +import { api } from "@/shared/lib/api/client"; import type { StateFilter } from "./stateFilter"; import { FeatureFlagTenantsToolbar } from "./FeatureFlagTenantsToolbar"; -import { DEFAULT_STATE_FILTER, ALL_STATE_FILTER, toApiState } from "./stateFilter"; +import { DEFAULT_STATE_FILTER, toApiState } from "./stateFilter"; import { TenantOverrideTable } from "./TenantOverrideTable"; interface TenantOverridesSectionProps { @@ -108,7 +108,7 @@ export function TenantOverridesSection({ {isLoading && tenants.length === 0 ? ( ) : tenants.length === 0 ? ( - + ) : ( <> ) { - const title = - state === FeatureFlagAudienceState.Enabled - ? t`No enabled accounts` - : state === FeatureFlagAudienceState.Disabled - ? t`No disabled accounts` - : state === ALL_STATE_FILTER - ? t`No accounts yet` - : t`No accounts match these filters`; - const description = hasFilters - ? t`Try clearing the search or filters to see more results.` - : t`Accounts will appear here as they become available.`; +function TenantOverridesEmpty({ hasFilters }: Readonly<{ hasFilters: boolean }>) { + const title = hasFilters ? t`No accounts found matching your search` : t`No accounts qualify for this feature yet`; return ( @@ -165,7 +155,6 @@ function TenantOverridesEmpty({ hasFilters, state }: Readonly<{ hasFilters: bool {title} - {description} ); diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx index 865b72a3b..d8872db3b 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverridesSection.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { TablePagination } from "@repo/ui/components/TablePagination"; import { keepPreviousData } from "@tanstack/react-query"; @@ -10,12 +10,12 @@ import { useCallback } from "react"; import type { UserRole } from "@/shared/lib/api/client"; -import { api, FeatureFlagAudienceState } from "@/shared/lib/api/client"; +import { api } from "@/shared/lib/api/client"; import type { StateFilter } from "./stateFilter"; import { FeatureFlagUsersToolbar } from "./FeatureFlagUsersToolbar"; -import { DEFAULT_STATE_FILTER, ALL_STATE_FILTER, toApiState } from "./stateFilter"; +import { DEFAULT_STATE_FILTER, toApiState } from "./stateFilter"; import { UserOverridesTable } from "./UserOverridesTable"; interface UserOverridesSectionProps { @@ -108,7 +108,7 @@ export function UserOverridesSection({ {isLoading && users.length === 0 ? ( ) : users.length === 0 ? ( - + ) : ( <> ) { - const title = - state === FeatureFlagAudienceState.Enabled - ? t`No enabled users` - : state === FeatureFlagAudienceState.Disabled - ? t`No disabled users` - : state === ALL_STATE_FILTER - ? t`No users yet` - : t`No users match these filters`; - const description = hasFilters - ? t`Try clearing the search or filters to see more results.` - : t`Users will appear here as they become available.`; +function UserOverridesEmpty({ hasFilters }: Readonly<{ hasFilters: boolean }>) { + const title = hasFilters ? t`No users found matching your search` : t`No users qualify for this feature yet`; return ( @@ -165,7 +155,6 @@ function UserOverridesEmpty({ hasFilters, state }: Readonly<{ hasFilters: boolea {title} - {description} ); diff --git a/application/account/BackOffice/routes/feature-flags/-components/types.ts b/application/account/BackOffice/routes/feature-flags/-components/types.ts index 400203dd8..c8061f058 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/types.ts +++ b/application/account/BackOffice/routes/feature-flags/-components/types.ts @@ -18,6 +18,8 @@ export interface FeatureFlagInfo { isActive: boolean; createdAt: string | null; requiredPlan: string | null; + orphanedAt: string | null; + isKillSwitchEnabled: boolean; } export interface GetFeatureFlagsResponse { diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 24c7ae81c..282bce627 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -112,6 +112,9 @@ msgstr "Konto" msgid "Account actions" msgstr "Kontohandlinger" +msgid "Account and user state is preserved but no longer evaluated. Delete the flag to remove the global row and all account and user overrides permanently." +msgstr "Konto- og brugertilstand er bevaret, men evalueres ikke længere. Slet flaget for at fjerne den globale række og alle konto- og brugertilsidesættelser permanent." + msgid "Account flags" msgstr "Kontoflag" @@ -150,9 +153,6 @@ msgstr "Konti inkluderes automatisk baseret på deres udrulningsbucket. Brug til msgid "Accounts will appear here as they are created." msgstr "Konti vises her, når de oprettes." -msgid "Accounts will appear here as they become available." -msgstr "Konti vises her, når de bliver tilgængelige." - msgid "Actions" msgstr "Handlinger" @@ -336,6 +336,12 @@ msgstr "Dato" msgid "Default" msgstr "Standard" +msgid "Delete feature flag" +msgstr "Slet feature flag" + +msgid "Delete flag and all overrides" +msgstr "Slet flag og alle tilsidesættelser" + msgid "Desktop" msgstr "Computer" @@ -432,6 +438,9 @@ msgstr "Feature flag aktiveret" msgid "Feature flag deactivated" msgstr "Feature flag deaktiveret" +msgid "Feature flag deleted: {flagName}" +msgstr "Feature flag slettet: {flagName}" + msgid "Feature flags" msgstr "Feature flags" @@ -591,12 +600,15 @@ msgstr "Næste" msgid "No account memberships" msgstr "Ingen kontomedlemskaber" -msgid "No accounts match these filters" -msgstr "Ingen konti matcher disse filtre" +msgid "No accounts found matching your search" +msgstr "Ingen konti fundet, der matcher din søgning" msgid "No accounts match your filters" msgstr "Ingen konti matcher dine filtre" +msgid "No accounts qualify for this feature yet" +msgstr "Ingen konti kvalificerer til denne funktion endnu" + msgid "No accounts yet" msgstr "Ingen konti endnu" @@ -618,18 +630,6 @@ msgstr "Ingen faktureringshændelser endnu" msgid "No change" msgstr "Ingen ændring" -msgid "No disabled accounts" -msgstr "Ingen deaktiverede konti" - -msgid "No disabled users" -msgstr "Ingen deaktiverede brugere" - -msgid "No enabled accounts" -msgstr "Ingen aktiverede konti" - -msgid "No enabled users" -msgstr "Ingen aktiverede brugere" - msgid "No feature flags" msgstr "Ingen feature flags" @@ -702,8 +702,8 @@ msgstr "Der er ingen brugeromfattede feature flags defineret." msgid "No users" msgstr "Ingen brugere" -msgid "No users match these filters" -msgstr "Ingen brugere matcher disse filtre" +msgid "No users found matching your search" +msgstr "Ingen brugere fundet, der matcher din søgning" msgid "No users match your filters." msgstr "Ingen brugere matcher dine filtre." @@ -711,6 +711,9 @@ msgstr "Ingen brugere matcher dine filtre." msgid "No users match your search" msgstr "Ingen brugere matcher din søgning" +msgid "No users qualify for this feature yet" +msgstr "Ingen brugere kvalificerer til denne funktion endnu" + msgid "No users yet" msgstr "Ingen brugere endnu" @@ -827,6 +830,9 @@ msgstr "Brugerflag. Brugere kan slå konfigurerbare flag til og fra. Admins styr msgid "Period" msgstr "Periode" +msgid "Permanently delete <0>{flagName} ({flagKey}) and all account and user overrides. This cannot be undone." +msgstr "Slet <0>{flagName} ({flagKey}) og alle konto- og brugertilsidesættelser permanent. Dette kan ikke fortrydes." + msgid "Plan" msgstr "Plan" @@ -937,6 +943,9 @@ msgstr "Fjern tilsidesættelse for {0}" msgid "Remove override for {flagName}" msgstr "Fjern tilsidesættelse for {flagName}" +msgid "Removed from definitions" +msgstr "Fjernet fra definitioner" + msgid "Renewal" msgstr "Fornyelse" @@ -1130,6 +1139,15 @@ msgstr "Denne konto har ingen brugere." msgid "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." msgstr "Dette flag styres af abonnementet. Det aktiveres automatisk for konti på det krævede abonnement eller højere." +msgid "This flag is platform-managed. Plan-based flags update with the account's subscription. Stable features are not togglable." +msgstr "Dette flag administreres af platformen. Abonnementsbaserede flag opdateres sammen med kontoens abonnement. Stabile funktioner kan ikke slås til eller fra." + +msgid "This flag no longer exists in code" +msgstr "Dette flag findes ikke længere i koden" + +msgid "This flag no longer exists in FeatureFlags.cs as of {formatted}. Account and user state is preserved but no longer evaluated by new code paths or telemetry. Use the delete action to remove the global row and all overrides." +msgstr "Dette flag findes ikke længere i FeatureFlags.cs pr. {formatted}. Konto- og brugertilstand er bevaret, men evalueres ikke længere af nye kodestier eller telemetri. Brug sletteknappen for at fjerne den globale række og alle tilsidesættelser." + msgid "this period" msgstr "denne periode" @@ -1215,9 +1233,6 @@ msgstr "Brugere inkluderes automatisk baseret på deres udrulningsbucket. Brug t msgid "Users will appear here as accounts are created." msgstr "Brugere vises her efterhånden som konti oprettes." -msgid "Users will appear here as they become available." -msgstr "Brugere vises her, når de bliver tilgængelige." - msgid "VAT" msgstr "Moms" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 6c564ed40..ac32540d7 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -112,6 +112,9 @@ msgstr "Account" msgid "Account actions" msgstr "Account actions" +msgid "Account and user state is preserved but no longer evaluated. Delete the flag to remove the global row and all account and user overrides permanently." +msgstr "Account and user state is preserved but no longer evaluated. Delete the flag to remove the global row and all account and user overrides permanently." + msgid "Account flags" msgstr "Account flags" @@ -150,9 +153,6 @@ msgstr "Accounts are automatically included based on their rollout bucket. Use o msgid "Accounts will appear here as they are created." msgstr "Accounts will appear here as they are created." -msgid "Accounts will appear here as they become available." -msgstr "Accounts will appear here as they become available." - msgid "Actions" msgstr "Actions" @@ -336,6 +336,12 @@ msgstr "Date" msgid "Default" msgstr "Default" +msgid "Delete feature flag" +msgstr "Delete feature flag" + +msgid "Delete flag and all overrides" +msgstr "Delete flag and all overrides" + msgid "Desktop" msgstr "Desktop" @@ -432,6 +438,9 @@ msgstr "Feature flag activated" msgid "Feature flag deactivated" msgstr "Feature flag deactivated" +msgid "Feature flag deleted: {flagName}" +msgstr "Feature flag deleted: {flagName}" + msgid "Feature flags" msgstr "Feature flags" @@ -591,12 +600,15 @@ msgstr "Next" msgid "No account memberships" msgstr "No account memberships" -msgid "No accounts match these filters" -msgstr "No accounts match these filters" +msgid "No accounts found matching your search" +msgstr "No accounts found matching your search" msgid "No accounts match your filters" msgstr "No accounts match your filters" +msgid "No accounts qualify for this feature yet" +msgstr "No accounts qualify for this feature yet" + msgid "No accounts yet" msgstr "No accounts yet" @@ -618,18 +630,6 @@ msgstr "No billing events yet" msgid "No change" msgstr "No change" -msgid "No disabled accounts" -msgstr "No disabled accounts" - -msgid "No disabled users" -msgstr "No disabled users" - -msgid "No enabled accounts" -msgstr "No enabled accounts" - -msgid "No enabled users" -msgstr "No enabled users" - msgid "No feature flags" msgstr "No feature flags" @@ -702,8 +702,8 @@ msgstr "No user-scoped feature flags are defined." msgid "No users" msgstr "No users" -msgid "No users match these filters" -msgstr "No users match these filters" +msgid "No users found matching your search" +msgstr "No users found matching your search" msgid "No users match your filters." msgstr "No users match your filters." @@ -711,6 +711,9 @@ msgstr "No users match your filters." msgid "No users match your search" msgstr "No users match your search" +msgid "No users qualify for this feature yet" +msgstr "No users qualify for this feature yet" + msgid "No users yet" msgstr "No users yet" @@ -827,6 +830,9 @@ msgstr "Per-user flags. Users can toggle configurable flags. Admins control A/B msgid "Period" msgstr "Period" +msgid "Permanently delete <0>{flagName} ({flagKey}) and all account and user overrides. This cannot be undone." +msgstr "Permanently delete <0>{flagName} ({flagKey}) and all account and user overrides. This cannot be undone." + msgid "Plan" msgstr "Plan" @@ -937,6 +943,9 @@ msgstr "Remove override for {0}" msgid "Remove override for {flagName}" msgstr "Remove override for {flagName}" +msgid "Removed from definitions" +msgstr "Removed from definitions" + msgid "Renewal" msgstr "Renewal" @@ -1130,6 +1139,15 @@ msgstr "This account has no users." msgid "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." msgstr "This flag is managed by the subscription plan. It is automatically enabled for accounts on the required plan or higher." +msgid "This flag is platform-managed. Plan-based flags update with the account's subscription. Stable features are not togglable." +msgstr "This flag is platform-managed. Plan-based flags update with the account's subscription. Stable features are not togglable." + +msgid "This flag no longer exists in code" +msgstr "This flag no longer exists in code" + +msgid "This flag no longer exists in FeatureFlags.cs as of {formatted}. Account and user state is preserved but no longer evaluated by new code paths or telemetry. Use the delete action to remove the global row and all overrides." +msgstr "This flag no longer exists in FeatureFlags.cs as of {formatted}. Account and user state is preserved but no longer evaluated by new code paths or telemetry. Use the delete action to remove the global row and all overrides." + msgid "this period" msgstr "this period" @@ -1215,9 +1233,6 @@ msgstr "Users are automatically included based on their rollout bucket. Use over msgid "Users will appear here as accounts are created." msgstr "Users will appear here as accounts are created." -msgid "Users will appear here as they become available." -msgstr "Users will appear here as they become available." - msgid "VAT" msgstr "VAT" diff --git a/application/shared-webapp/infrastructure/featureFlags/registry.ts b/application/shared-webapp/infrastructure/featureFlags/registry.ts index 2cd95ea40..9016f6078 100644 --- a/application/shared-webapp/infrastructure/featureFlags/registry.ts +++ b/application/shared-webapp/infrastructure/featureFlags/registry.ts @@ -49,6 +49,13 @@ const featureFlagRegistry: Record = { parentDependency: null, description: "Enables compact view in the user interface" }, + "experimental-ui": { + key: "experimental-ui", + scope: "user", + adminLevel: "user", + parentDependency: null, + description: "Enables experimental UI components for users" + }, "google-oauth": { key: "google-oauth", scope: "system", From 8ceb316439bb3b85b7e4eb1f0eee7ada326f4e1f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 18:22:48 +0200 Subject: [PATCH 057/155] Replace flag-version DB pings with x-user-feature-flags header --- .../AuthenticationCookieMiddleware.cs | 19 ++++++++ .../Api/BackOffice/FeatureFlagEndpoints.cs | 16 +++---- .../Api/Endpoints/FeatureFlagEndpoints.cs | 4 +- .../FeatureFlagVersionMiddleware.cs | 36 -------------- application/account/Api/Program.cs | 6 +-- ...75141_DropFeatureFlagVersionFromTenants.cs | 14 ++++++ .../Commands/ActivateFeatureFlag.cs | 5 +- .../Commands/DeactivateFeatureFlag.cs | 5 +- .../RemoveTenantFeatureFlagOverride.cs | 10 +--- .../Commands/RemoveUserFeatureFlagOverride.cs | 10 +--- .../SetFeatureFlagRolloutPercentage.cs | 5 +- .../Commands/SetTenantFeatureFlagInternal.cs | 10 +--- .../Commands/SetTenantFeatureFlagOwner.cs | 7 +-- .../Commands/SetUserFeatureFlag.cs | 7 +-- .../Commands/SetUserFeatureFlagInternal.cs | 10 +--- .../Shared/PlanBasedFeatureFlagEvaluator.cs | 17 +------ .../Core/Features/Tenants/Domain/Tenant.cs | 7 --- .../Tenants/Domain/TenantRepository.cs | 14 ------ .../Features/Users/Shared/UserInfoFactory.cs | 1 - .../Authentication/GetUserSessionsTests.cs | 3 +- .../Tests/Authentication/SwitchTenantTests.cs | 15 ++---- .../GetDashboardMrrConsistencySummaryTests.cs | 3 +- .../GetUnsyncedSubscriptionsSummaryTests.cs | 3 +- .../Dashboard/GetDashboardKpisTests.cs | 3 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 3 +- .../GetDashboardPlanDistributionTests.cs | 3 +- .../GetDashboardRecentSignupsTests.cs | 3 +- .../GetDashboardRecentStripeEventsTests.cs | 3 +- .../GetDashboardRevenueTrendTests.cs | 3 +- .../Dashboard/GetDashboardTrendsTests.cs | 3 +- .../FeatureFlagBackOfficeTests.cs | 48 +++++-------------- .../GetBackOfficeBillingEventsTests.cs | 3 +- .../CompleteEmailLoginTests.cs | 6 +-- .../CompleteExternalLoginTests.cs | 6 +-- .../Tests/FeatureFlags/FeatureFlagTests.cs | 34 ++++--------- .../PlanBasedFeatureFlagEvaluatorTests.cs | 47 ------------------ .../Domain/BillingEventRepositoryTests.cs | 3 +- .../SubscriptionRepositoryDriftScopeTests.cs | 3 +- .../Domain/SubscriptionRepositoryTests.cs | 3 +- .../BackOffice/GetTenantDetailTests.cs | 9 ++-- .../Tenants/BackOffice/GetTenantUsersTests.cs | 3 +- .../Tenants/BackOffice/GetTenantsTests.cs | 3 +- .../Tests/Tenants/GetTenantsForUserTests.cs | 12 ++--- .../GetBackOfficeUserDetailTests.cs | 3 +- .../GetBackOfficeUserSessionsTests.cs | 3 +- .../BackOffice/GetBackOfficeUsersTests.cs | 3 +- .../Tests/Users/DeclineInvitationTests.cs | 9 ++-- .../Tests/Workers/BillingDriftWorkerTests.cs | 3 +- .../AuthenticationTokenHttpKeys.cs | 2 + .../TokenGeneration/AccessTokenGenerator.cs | 1 - .../SharedKernel/Authentication/UserInfo.cs | 4 -- 51 files changed, 116 insertions(+), 340 deletions(-) delete mode 100644 application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs create mode 100644 application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index e161af8c0..d65e7048b 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -16,6 +16,7 @@ ILogger logger { private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account/authentication/refresh-authentication-tokens"; private const string UnauthorizedReasonItemKey = "UnauthorizedReason"; + private const string CurrentAccessTokenItemKey = "CurrentAccessToken"; private static readonly JsonWebTokenHandler TokenHandler = new(); @@ -70,6 +71,14 @@ async Task HandleRefreshAsync() { await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); } + + // Emit x-user-feature-flags on every authenticated response so the SPA can converge on the + // current set of enabled flags without polling. Value reflects the access token in effect + // after any refresh that just happened. + if (context.Items.TryGetValue(CurrentAccessTokenItemKey, out var tokenItem) && tokenItem is string currentAccessToken) + { + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(currentAccessToken); + } } // Register OnStarting BEFORE next(context). YARP streams responses back, so by the time control @@ -118,6 +127,7 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http } context.Request.Headers.Authorization = $"Bearer {accessToken}"; + context.Items[CurrentAccessTokenItemKey] = accessToken; } catch (SessionRevokedException ex) { @@ -233,6 +243,15 @@ private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext contex context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); + + context.Items[CurrentAccessTokenItemKey] = accessToken; + } + + private static string ExtractFeatureFlagsClaim(string accessToken) + { + if (!TokenHandler.CanReadToken(accessToken)) return string.Empty; + var jwt = TokenHandler.ReadJsonWebToken(accessToken); + return jwt.TryGetClaim("feature_flags", out var claim) ? claim.Value : string.Empty; } private async Task ExtractExpirationFromTokenAsync(string token) diff --git a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs index 2f024933f..3844ddc64 100644 --- a/application/account/Api/BackOffice/FeatureFlagEndpoints.cs +++ b/application/account/Api/BackOffice/FeatureFlagEndpoints.cs @@ -34,27 +34,27 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPut("/{flagKey}/activate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new ActivateFeatureFlagCommand(flagKey)) - ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + ).RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); group.MapPut("/{flagKey}/deactivate", async Task (string flagKey, IMediator mediator) => await mediator.Send(new DeactivateFeatureFlagCommand(flagKey)) - ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + ).RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); group.MapDelete("/{flagKey}", async Task (string flagKey, IMediator mediator) => await mediator.Send(new DeleteFeatureFlagCommand(flagKey)) - ).DisableAntiforgery().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + ).RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ).DisableAntiforgery(); + ); group.MapPut("/{flagKey}/rollout-percentage", async Task (string flagKey, SetFeatureFlagRolloutPercentageCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ).DisableAntiforgery(); + ); group.MapDelete("/{flagKey}/tenant-override", async Task (string flagKey, TenantId tenantId, IMediator mediator) => await mediator.Send(new RemoveTenantFeatureFlagOverrideCommand { FlagKey = flagKey, TenantId = tenantId }) - ).DisableAntiforgery(); + ); group.MapGet("/{flagKey}/users", async Task> (string flagKey, [AsParameters] GetFeatureFlagUsersQuery query, IMediator mediator) => await mediator.Send(query with { FlagKey = flagKey }) @@ -62,10 +62,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagInternalCommand command, IMediator mediator) => await mediator.Send(command with { FlagKey = flagKey }) - ).DisableAntiforgery(); + ); group.MapDelete("/{flagKey}/user-override", async Task (string flagKey, UserId userId, TenantId tenantId, IMediator mediator) => await mediator.Send(new RemoveUserFeatureFlagOverrideCommand { FlagKey = flagKey, UserId = userId, TenantId = tenantId }) - ).DisableAntiforgery(); + ); } } diff --git a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs index af4cc09fc..b6cff2408 100644 --- a/application/account/Api/Endpoints/FeatureFlagEndpoints.cs +++ b/application/account/Api/Endpoints/FeatureFlagEndpoints.cs @@ -21,11 +21,11 @@ public void MapEndpoints(IEndpointRouteBuilder routes) ).Produces(); group.MapPut("/{flagKey}/tenant-override", async Task (string flagKey, SetTenantFeatureFlagOwnerCommand command, IMediator mediator) - => await mediator.Send(command with { FlagKey = flagKey }) + => (await mediator.Send(command with { FlagKey = flagKey })).AddRefreshAuthenticationTokens() ); group.MapPut("/{flagKey}/user-override", async Task (string flagKey, SetUserFeatureFlagCommand command, IMediator mediator) - => await mediator.Send(command with { FlagKey = flagKey }) + => (await mediator.Send(command with { FlagKey = flagKey })).AddRefreshAuthenticationTokens() ); } } diff --git a/application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs b/application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs deleted file mode 100644 index 0ff607410..000000000 --- a/application/account/Api/Middleware/FeatureFlagVersionMiddleware.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Account.Features.Tenants.Domain; -using SharedKernel.Authentication; -using SharedKernel.Domain; - -namespace Account.Api.Middleware; - -public sealed class FeatureFlagVersionMiddleware(ITenantRepository tenantRepository) : IMiddleware -{ - public async Task InvokeAsync(HttpContext context, RequestDelegate next) - { - if (context.User.Identity?.IsAuthenticated == true) - { - var tenantIdClaim = context.User.FindFirst("tenant_id")?.Value; - var versionClaim = context.User.FindFirst("feature_flag_version")?.Value; - - if (tenantIdClaim is not null && long.TryParse(tenantIdClaim, out var tenantIdValue) && - versionClaim is not null && int.TryParse(versionClaim, out var jwtVersion)) - { - var tenantId = new TenantId(tenantIdValue); - var currentVersion = await tenantRepository.GetFeatureFlagVersionAsync(tenantId, context.RequestAborted); - - if (jwtVersion != currentVersion) - { - context.Response.OnStarting(() => - { - context.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; - return Task.CompletedTask; - } - ); - } - } - } - - await next(context); - } -} diff --git a/application/account/Api/Program.cs b/application/account/Api/Program.cs index 2ffd6a013..677333590 100644 --- a/application/account/Api/Program.cs +++ b/application/account/Api/Program.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Account; using Account.Api; -using Account.Api.Middleware; using Microsoft.Extensions.Options; using SharedKernel.Authentication; using SharedKernel.Authentication.BackOfficeIdentity; @@ -23,8 +22,7 @@ builder.Services .AddApiServices([Assembly.GetExecutingAssembly(), Configuration.Assembly], ApiDocumentLayout.AccountAndBackOffice) .AddAccountServices() - .AddBackOfficeDevStaticProxy() - .AddScoped(); + .AddBackOfficeDevStaticProxy(); var app = builder.Build(); @@ -141,8 +139,6 @@ ); } -app.UseMiddleware(); - await app.RunAsync(); return; diff --git a/application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs b/application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs new file mode 100644 index 000000000..a534007dc --- /dev/null +++ b/application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260513175141_DropFeatureFlagVersionFromTenants")] +public sealed class DropFeatureFlagVersionFromTenants : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn("feature_flag_version", "tenants"); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs index 338cd3378..806fc0069 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/ActivateFeatureFlag.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -20,7 +19,7 @@ public ActivateFeatureFlagValidator() } } -public sealed class ActivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class ActivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(ActivateFeatureFlagCommand command, CancellationToken cancellationToken) @@ -31,8 +30,6 @@ public async Task Handle(ActivateFeatureFlagCommand command, Cancellatio featureFlag.Activate(timeProvider.GetUtcNow()); featureFlagRepository.Update(featureFlag); - await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); - events.CollectEvent(new FeatureFlagActivated(command.FlagKey)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs index 52a37518d..12a77272a 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/DeactivateFeatureFlag.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -20,7 +19,7 @@ public DeactivateFeatureFlagValidator() } } -public sealed class DeactivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class DeactivateFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(DeactivateFeatureFlagCommand command, CancellationToken cancellationToken) @@ -31,8 +30,6 @@ public async Task Handle(DeactivateFeatureFlagCommand command, Cancellat featureFlag.Deactivate(timeProvider.GetUtcNow()); featureFlagRepository.Update(featureFlag); - await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); - events.CollectEvent(new FeatureFlagDeactivated(command.FlagKey)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs index e516cfd93..38f730eaa 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -30,7 +29,7 @@ public RemoveTenantFeatureFlagOverrideValidator() } } -public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) +public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(RemoveTenantFeatureFlagOverrideCommand command, CancellationToken cancellationToken) @@ -40,13 +39,6 @@ public async Task Handle(RemoveTenantFeatureFlagOverrideCommand command, featureFlagRepository.Remove(tenantOverride); - var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); - if (tenant is not null) - { - tenant.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - } - events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs index c54ecf691..e57827fd5 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -31,7 +30,7 @@ public RemoveUserFeatureFlagOverrideValidator() } } -public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) +public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(RemoveUserFeatureFlagOverrideCommand command, CancellationToken cancellationToken) @@ -41,13 +40,6 @@ public async Task Handle(RemoveUserFeatureFlagOverrideCommand command, C featureFlagRepository.Remove(userOverride); - var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); - if (tenant is not null) - { - tenant.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - } - events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId.Value)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs index 25ceb5cdf..43a82b8e6 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetFeatureFlagRolloutPercentage.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -30,7 +29,7 @@ public SetFeatureFlagRolloutPercentageValidator() } } -public sealed class SetFeatureFlagRolloutPercentageHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) +public sealed class SetFeatureFlagRolloutPercentageHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, CancellationToken cancellationToken) @@ -60,8 +59,6 @@ public async Task Handle(SetFeatureFlagRolloutPercentageCommand command, featureFlag.SetRolloutRange(rolloutBucketStart, rolloutBucketEnd); featureFlagRepository.Update(featureFlag); - await tenantRepository.IncrementAllFeatureFlagVersionsAsync(cancellationToken); - events.CollectEvent(new FeatureFlagRolloutPercentageUpdated(command.FlagKey, command.RolloutPercentage)); return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 57b0f21d5..67957d2bd 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -32,7 +31,7 @@ public SetTenantFeatureFlagInternalValidator() } } -public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetTenantFeatureFlagInternalCommand command, CancellationToken cancellationToken) @@ -73,13 +72,6 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); } - var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); - if (tenant is not null) - { - tenant.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - } - return Result.Success(); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs index c0cecce9e..dde084bcb 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; @@ -30,7 +29,7 @@ public SetTenantFeatureFlagOwnerValidator() } } -public sealed class SetTenantFeatureFlagOwnerHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetTenantFeatureFlagOwnerHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetTenantFeatureFlagOwnerCommand command, CancellationToken cancellationToken) @@ -79,10 +78,6 @@ public async Task Handle(SetTenantFeatureFlagOwnerCommand command, Cance } } - var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); - tenant!.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - return Result.Success(); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs index 622e39da6..1b9a9d2a1 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -29,7 +28,7 @@ public SetUserFeatureFlagValidator() } } -public sealed class SetUserFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetUserFeatureFlagHandler(IFeatureFlagRepository featureFlagRepository, IExecutionContext executionContext, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetUserFeatureFlagCommand command, CancellationToken cancellationToken) @@ -74,10 +73,6 @@ public async Task Handle(SetUserFeatureFlagCommand command, Cancellation } } - var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); - tenant!.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - return Result.Success(); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs index 5d4c727ce..86013196f 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -1,5 +1,4 @@ using Account.Features.FeatureFlags.Domain; -using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -33,7 +32,7 @@ public SetUserFeatureFlagInternalValidator() } } -public sealed class SetUserFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetUserFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetUserFeatureFlagInternalCommand command, CancellationToken cancellationToken) @@ -74,13 +73,6 @@ public async Task Handle(SetUserFeatureFlagInternalCommand command, Canc events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId.Value)); } - var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); - if (tenant is not null) - { - tenant.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - } - return Result.Success(); } } diff --git a/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs index 14fd30fba..fadb6008c 100644 --- a/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs @@ -1,12 +1,11 @@ using Account.Features.FeatureFlags.Domain; using Account.Features.Subscriptions.Domain; -using Account.Features.Tenants.Domain; using SharedKernel.Domain; using SharedKernel.FeatureFlags; namespace Account.Features.FeatureFlags.Shared; -public sealed class PlanBasedFeatureFlagEvaluator(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider) +public sealed class PlanBasedFeatureFlagEvaluator(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider) { public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, SubscriptionPlan subscriptionPlan, CancellationToken cancellationToken) { @@ -18,7 +17,6 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio var existingOverrides = await featureFlagRepository.GetPlanBasedOverridesForTenantAsync(tenantId.Value, cancellationToken); var overridesByKey = existingOverrides.ToDictionary(f => f.FlagKey); var now = timeProvider.GetUtcNow(); - var changed = false; foreach (var definition in planFeatureFlagDefinitions) { @@ -32,13 +30,11 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio var featureFlag = FeatureFlag.CreateTenantOverride(definition.Key, tenantId.Value, FeatureFlagSource.Plan); featureFlag.Activate(now); await featureFlagRepository.AddAsync(featureFlag, cancellationToken); - changed = true; } else if (!existingOverride.IsActive) { existingOverride.Activate(now); featureFlagRepository.Update(existingOverride); - changed = true; } } else @@ -47,20 +43,9 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio { existingOverride.Deactivate(now); featureFlagRepository.Update(existingOverride); - changed = true; } } } - - if (changed) - { - var tenant = await tenantRepository.GetByIdUnfilteredAsync(tenantId, cancellationToken); - if (tenant is not null) - { - tenant.IncrementFeatureFlagVersion(); - tenantRepository.Update(tenant); - } - } } private static PlanTier MapToPlanTier(SubscriptionPlan plan) diff --git a/application/account/Core/Features/Tenants/Domain/Tenant.cs b/application/account/Core/Features/Tenants/Domain/Tenant.cs index a27165aa2..5d7d77897 100644 --- a/application/account/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account/Core/Features/Tenants/Domain/Tenant.cs @@ -28,8 +28,6 @@ private Tenant(int rolloutBucket) : base(TenantId.NewId()) public int RolloutBucket { get; private set; } - public int FeatureFlagVersion { get; private set; } - public static Tenant Create(string email, int existingCount) { var tenant = new Tenant(RolloutBucketHasher.ComputeRolloutBucket(existingCount)); @@ -70,11 +68,6 @@ public void UpdatePlan(SubscriptionPlan plan) { Plan = plan; } - - public void IncrementFeatureFlagVersion() - { - FeatureFlagVersion++; - } } public sealed record Logo(string? Url = null, int Version = 0); diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 34dd3a8e4..8a51dd2ae 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -61,10 +61,6 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// This method is used to compute rollout buckets for new tenants. ///
Task GetCountUnfilteredAsync(CancellationToken cancellationToken); - - Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken); - - Task IncrementAllFeatureFlagVersionsAsync(CancellationToken cancellationToken); } public sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -176,14 +172,4 @@ public async Task GetCountUnfilteredAsync(CancellationToken cancellationTok { return await DbSet.IgnoreQueryFilters().CountAsync(cancellationToken); } - - public async Task GetFeatureFlagVersionAsync(TenantId tenantId, CancellationToken cancellationToken) - { - return await DbSet.Where(t => t.Id == tenantId).Select(t => t.FeatureFlagVersion).SingleOrDefaultAsync(cancellationToken); - } - - public async Task IncrementAllFeatureFlagVersionsAsync(CancellationToken cancellationToken) - { - await accountDbContext.Database.ExecuteSqlRawAsync("UPDATE tenants SET feature_flag_version = feature_flag_version + 1", cancellationToken); - } } diff --git a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs index 445700a4e..16b4e1d27 100644 --- a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs @@ -47,7 +47,6 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se Locale = user.Locale, IsInternalUser = user.IsInternalUser, FeatureFlags = new HashSet(enabledFlags), - FeatureFlagVersion = tenant.FeatureFlagVersion, TenantRolloutBucket = tenant.RolloutBucket, UserRolloutBucket = user.RolloutBucket }; diff --git a/application/account/Tests/Authentication/GetUserSessionsTests.cs b/application/account/Tests/Authentication/GetUserSessionsTests.cs index 916d3cdd1..20428a570 100644 --- a/application/account/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account/Tests/Authentication/GetUserSessionsTests.cs @@ -137,8 +137,7 @@ private long InsertTenant(string name) ("state", "Active"), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index 1ee1b21d2..cb84b6230 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -32,8 +32,7 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -116,8 +115,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -180,8 +178,7 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -254,8 +251,7 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -336,8 +332,7 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs index db17bb011..a27cefb81 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs @@ -116,8 +116,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs index 01590d91d..2513c3844 100644 --- a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs +++ b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs @@ -66,8 +66,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index 43511e5c1..fafcea2d1 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -266,8 +266,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, DateTimeOffset c ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 40aacd324..9e1f95f66 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -115,8 +115,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs index 3516cc353..754a47b4f 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardPlanDistributionTests.cs @@ -90,8 +90,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan) ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs index 4eaf136dd..d713b431c 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentSignupsTests.cs @@ -83,8 +83,7 @@ private void SeedTenant(string name, DateTimeOffset createdAt) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); } diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs index c7b0395a1..004757544 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRecentStripeEventsTests.cs @@ -111,8 +111,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs index 833639cbb..f10da66b0 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardRevenueTrendTests.cs @@ -304,8 +304,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 0), - ("feature_flag_version", 0) + ("rollout_bucket", 0) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs index 92d6e4835..46af029ed 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardTrendsTests.cs @@ -191,8 +191,7 @@ private TenantId SeedTenant(string name, DateTimeOffset createdAt) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs index 966b6b9c1..19b536719 100644 --- a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs +++ b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs @@ -6,6 +6,7 @@ using Account.Features.Subscriptions.Domain; using Account.Features.Users.Domain; using FluentAssertions; +using SharedKernel.Authentication; using SharedKernel.Authentication.MockEasyAuth; using SharedKernel.Domain; using SharedKernel.Tests; @@ -1126,17 +1127,15 @@ public async Task GetFeatureFlags_WhenCalled_ShouldReturnAllFlagsWithDatabaseSta result.Flags.Single(f => f.Key == "compact-view").ConfigurableByUser.Should().BeTrue(); } - // JWT-invalidation: kill-switch and per-tenant override both bump feature_flag_version. + // Back-office mutations do NOT chain AddRefreshAuthenticationTokens() because the back-office + // actor's own claim set is unchanged when they mutate flags for other tenants/users. Target + // sessions pick up the change via the x-user-feature-flags header on their next request. [Fact] - public async Task ActivateFeatureFlag_WhenCalledByAdmin_ShouldIncrementAllTenantsFeatureFlagVersion() + public async Task ActivateFeatureFlag_WhenCalledByAdmin_ShouldNotAddRefreshAuthenticationTokensHeader() { // Arrange var flagKey = "sso"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); using var client = CreateAdminBackOfficeClient(); // Act @@ -1144,21 +1143,14 @@ public async Task ActivateFeatureFlag_WhenCalledByAdmin_ShouldIncrementAllTenant // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); + response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } [Fact] - public async Task DeactivateFeatureFlag_WhenCalledByAdmin_ShouldIncrementAllTenantsFeatureFlagVersion() + public async Task DeactivateFeatureFlag_WhenCalledByAdmin_ShouldNotAddRefreshAuthenticationTokensHeader() { // Arrange var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); using var client = CreateAdminBackOfficeClient(); // Act @@ -1166,21 +1158,15 @@ public async Task DeactivateFeatureFlag_WhenCalledByAdmin_ShouldIncrementAllTena // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); + response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } [Fact] - public async Task SetTenantOverride_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + public async Task SetTenantOverride_WhenCalled_ShouldNotAddRefreshAuthenticationTokensHeader() { // Arrange var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); using var client = CreateRegularBackOfficeClient(); var command = new SetTenantFeatureFlagInternalCommand { TenantId = tenantId, Enabled = true }; @@ -1189,21 +1175,14 @@ public async Task SetTenantOverride_WhenCalled_ShouldIncrementTenantFeatureFlagV // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); + response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } [Fact] - public async Task SetRolloutPercentage_WhenCalled_ShouldIncrementAllTenantsFeatureFlagVersion() + public async Task SetRolloutPercentage_WhenCalled_ShouldNotAddRefreshAuthenticationTokensHeader() { // Arrange var flagKey = "beta-features"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); using var client = CreateRegularBackOfficeClient(); var command = new SetFeatureFlagRolloutPercentageCommand { RolloutPercentage = 50 }; @@ -1212,10 +1191,7 @@ public async Task SetRolloutPercentage_WhenCalled_ShouldIncrementAllTenantsFeatu // Assert response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); + response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } // Delete feature flag (AdminPolicyName, orphan-only) diff --git a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs index 7ea11ab5c..f90a09f5b 100644 --- a/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs +++ b/application/account/Tests/BackOffice/GetBackOfficeBillingEventsTests.cs @@ -252,8 +252,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index 0db2344ab..f6bbafffa 100644 --- a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -220,8 +220,7 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -325,8 +324,7 @@ public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_Sh ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs index 1e84414fe..52911f9c3 100644 --- a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -412,8 +412,7 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -597,8 +596,7 @@ public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs index f745fe4a4..bfb1845b8 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagTests.cs @@ -4,9 +4,9 @@ using Account.Features.FeatureFlags.Commands; using Account.Features.FeatureFlags.Queries; using FluentAssertions; +using SharedKernel.Authentication; using SharedKernel.FeatureFlags; using SharedKernel.Tests; -using SharedKernel.Tests.Persistence; using Xunit; namespace Account.Tests.FeatureFlags; @@ -153,53 +153,37 @@ public async Task GetUserConfigurableFlags_WhenCalled_ShouldReturnConfigurableUs result.Flags.Should().Contain(f => f.FlagKey == "compact-view" && f.Enabled == false); } - // JWT-invalidation tests for owner/user-facing routes (cross-tenant kill-switch invalidation tests - // live in FeatureFlagBackOfficeTests.cs since the trigger is back-office). + // Self-service flag mutations chain AddRefreshAuthenticationTokens() so the actor's own JWT is + // refreshed by the gateway in the same request cycle. Back-office mutations do not. [Fact] - public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + public async Task SetTenantFeatureFlagOwner_WhenCalled_ShouldAddRefreshAuthenticationTokensHeader() { // Arrange var flagKey = "custom-branding"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); var command = new SetTenantFeatureFlagOwnerCommand { Enabled = true }; // Act var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/tenant-override", command); // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.Should().ContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } [Fact] - public async Task SetUserFeatureFlag_WhenCalled_ShouldIncrementTenantFeatureFlagVersion() + public async Task SetUserFeatureFlag_WhenCalled_ShouldAddRefreshAuthenticationTokensHeader() { // Arrange var flagKey = "compact-view"; - var tenantId = DatabaseSeeder.Tenant1.Id; - var originalVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); var command = new SetUserFeatureFlagCommand { Enabled = true }; // Act var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync($"/api/account/feature-flags/{flagKey}/user-override", command); // Assert - response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); - - var updatedVersion = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", [new { tenantId = tenantId.Value }] - ); - updatedVersion.Should().Be(originalVersion + 1); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.Should().ContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } // A/B rollout bucket math (pure unit tests, no HTTP) diff --git a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs index 501f15a43..8e7763098 100644 --- a/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs +++ b/application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs @@ -94,51 +94,4 @@ public async Task EvaluatePlanFlags_WhenAlreadyActive_ShouldBeIdempotent() ); secondEnabledAt.Should().Be(firstEnabledAt); } - - [Fact] - public async Task EvaluatePlanFlags_WhenUpgraded_ShouldIncrementFeatureFlagVersion() - { - // Arrange - var tenantId = DatabaseSeeder.Tenant1.Id; - var versionBefore = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", - [new { tenantId = tenantId.Value }] - ); - - // Act - await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); - await _dbContext.SaveChangesAsync(); - - // Assert - var versionAfter = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", - [new { tenantId = tenantId.Value }] - ); - versionAfter.Should().Be(versionBefore + 1); - } - - [Fact] - public async Task EvaluatePlanFlags_WhenNoChange_ShouldNotIncrementFeatureFlagVersion() - { - // Arrange - var tenantId = DatabaseSeeder.Tenant1.Id; - await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); - await _dbContext.SaveChangesAsync(); - - var versionAfterFirst = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", - [new { tenantId = tenantId.Value }] - ); - - // Act - await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None); - await _dbContext.SaveChangesAsync(); - - // Assert - var versionAfterSecond = Connection.ExecuteScalar( - "SELECT feature_flag_version FROM tenants WHERE id = @tenantId", - [new { tenantId = tenantId.Value }] - ); - versionAfterSecond.Should().Be(versionAfterFirst); - } } diff --git a/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs b/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs index e632d38ed..23da04e32 100644 --- a/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs +++ b/application/account/Tests/Subscriptions/Domain/BillingEventRepositoryTests.cs @@ -55,8 +55,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 0), - ("feature_flag_version", 0) + ("rollout_bucket", 0) ] ); return tenantId; diff --git a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs index 85edc8485..a8782e513 100644 --- a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs +++ b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryDriftScopeTests.cs @@ -78,8 +78,7 @@ private TenantId SeedTenant(string name, string plan = nameof(SubscriptionPlan.P ("state", nameof(TenantState.Active)), ("plan", plan), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 0), - ("feature_flag_version", 0) + ("rollout_bucket", 0) ] ); return tenantId; diff --git a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs index 4ce36568d..768561acc 100644 --- a/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs +++ b/application/account/Tests/Subscriptions/Domain/SubscriptionRepositoryTests.cs @@ -55,8 +55,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 0), - ("feature_flag_version", 0) + ("rollout_bucket", 0) ] ); return tenantId; diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index ecaa32568..1d82eb1d5 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -30,8 +30,7 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); @@ -103,8 +102,7 @@ public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscr ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":1}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); @@ -135,8 +133,7 @@ public async Task GetTenantDetail_WhenSubscriptionHasRefundedTransaction_ShouldE ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Premium)), ("logo", """{"Url":null,"Version":1}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs index f6b7d99a4..6a2d96f1b 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs @@ -31,8 +31,7 @@ public async Task GetTenantUsers_WhenCalled_ShouldReturnUsersForThatTenantOnly() ("state", "Active"), ("plan", "Basis"), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); SeedUser(otherTenantId, "outsider@other.com", "Outsider", null, UserRole.Member); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index 8495301a2..d3edbe3c6 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -380,8 +380,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); diff --git a/application/account/Tests/Tenants/GetTenantsForUserTests.cs b/application/account/Tests/Tenants/GetTenantsForUserTests.cs index 498ec1544..9deec0ca0 100644 --- a/application/account/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account/Tests/Tenants/GetTenantsForUserTests.cs @@ -32,8 +32,7 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -110,8 +109,7 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -159,8 +157,7 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -209,8 +206,7 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs index d9b8f645a..e875aac5c 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs @@ -127,8 +127,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs index 81966c546..26f012e85 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserSessionsTests.cs @@ -126,8 +126,7 @@ private TenantId SeedTenant(string name) ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Basis)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); return tenantId; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index 3751fb904..247726e23 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -287,8 +287,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan = SubscriptionPla ("state", nameof(TenantState.Active)), ("plan", plan.ToString()), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 50), - ("feature_flag_version", 0) + ("rollout_bucket", 50) ] ); diff --git a/application/account/Tests/Users/DeclineInvitationTests.cs b/application/account/Tests/Users/DeclineInvitationTests.cs index 7a7ebca18..09166040c 100644 --- a/application/account/Tests/Users/DeclineInvitationTests.cs +++ b/application/account/Tests/Users/DeclineInvitationTests.cs @@ -31,8 +31,7 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -107,8 +106,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); @@ -120,8 +118,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("state", nameof(TenantState.Active)), ("logo", """{"Url":null,"Version":0}"""), ("plan", nameof(SubscriptionPlan.Basis)), - ("rollout_bucket", 42), - ("feature_flag_version", 0) + ("rollout_bucket", 42) ] ); diff --git a/application/account/Tests/Workers/BillingDriftWorkerTests.cs b/application/account/Tests/Workers/BillingDriftWorkerTests.cs index bd704f37e..1bf86f713 100644 --- a/application/account/Tests/Workers/BillingDriftWorkerTests.cs +++ b/application/account/Tests/Workers/BillingDriftWorkerTests.cs @@ -240,8 +240,7 @@ private long SeedExtraTenantWithSubscription(string stripeCustomerId, string str ("state", nameof(TenantState.Active)), ("plan", nameof(SubscriptionPlan.Standard)), ("logo", """{"Url":null,"Version":0}"""), - ("rollout_bucket", 0), - ("feature_flag_version", 0) + ("rollout_bucket", 0) ] ); diff --git a/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs b/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs index 9ebd3f939..de74bd331 100644 --- a/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs +++ b/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs @@ -10,6 +10,8 @@ public static class AuthenticationTokenHttpKeys public const string RefreshAuthenticationTokensHeaderKey = "x-refresh-authentication-tokens-required"; + public const string UserFeatureFlagsHeaderKey = "x-user-feature-flags"; + public const string UnauthorizedReasonHeaderKey = "x-unauthorized-reason"; // __Host prefix ensures the cookie is sent only to the host, requires Secure, HTTPS, Path=/ and no Domain specified diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index 2c45a9a58..16bb96236 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -30,7 +30,6 @@ public string Generate(UserInfo userInfo) new Claim("locale", userInfo.Locale!), new Claim("session_id", userInfo.SessionId?.ToString() ?? string.Empty), new Claim("feature_flags", string.Join(",", userInfo.FeatureFlags)), - new Claim("feature_flag_version", userInfo.FeatureFlagVersion.ToString()), new Claim("tenant_rollout_bucket", userInfo.TenantRolloutBucket.ToString()), new Claim("user_rollout_bucket", userInfo.UserRolloutBucket?.ToString() ?? string.Empty) ] diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index 645be2b78..da6a4eb19 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -61,8 +61,6 @@ public class UserInfo public IReadOnlySet FeatureFlags { get; init; } = EmptyFeatureFlags; - public int FeatureFlagVersion { get; init; } - public int TenantRolloutBucket { get; init; } public int? UserRolloutBucket { get; init; } @@ -90,7 +88,6 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, stri var sessionId = user.FindFirstValue("session_id"); var email = user.FindFirstValue(ClaimTypes.Email); var featureFlagsClaim = user.FindFirstValue("feature_flags"); - var featureFlagVersionClaim = user.FindFirstValue("feature_flag_version"); var tenantRolloutBucketClaim = user.FindFirstValue("tenant_rollout_bucket"); var userRolloutBucketClaim = user.FindFirstValue("user_rollout_bucket"); return new UserInfo @@ -113,7 +110,6 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale, stri Theme = theme, IsInternalUser = IsInternalUserEmail(email), FeatureFlags = ParseFeatureFlags(featureFlagsClaim), - FeatureFlagVersion = !string.IsNullOrEmpty(featureFlagVersionClaim) ? int.Parse(featureFlagVersionClaim) : 0, TenantRolloutBucket = !string.IsNullOrEmpty(tenantRolloutBucketClaim) ? int.Parse(tenantRolloutBucketClaim) : 0, UserRolloutBucket = !string.IsNullOrEmpty(userRolloutBucketClaim) ? int.Parse(userRolloutBucketClaim) : null }; From c6d51e00b1abe58206ac1e5d5ceec07f931eacb8 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 18:30:45 +0200 Subject: [PATCH 058/155] Read x-user-feature-flags header into AuthenticationProvider state for SPA reactivity --- .../auth/AuthenticationProvider.tsx | 26 ++++++++++++- .../featureFlags/useFeatureFlag.ts | 8 +++- .../featureFlags/userFeatureFlagsHeader.ts | 38 +++++++++++++++++++ .../infrastructure/http/httpClient.ts | 3 ++ .../infrastructure/http/queryClient.ts | 3 ++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 application/shared-webapp/infrastructure/featureFlags/userFeatureFlagsHeader.ts diff --git a/application/shared-webapp/infrastructure/auth/AuthenticationProvider.tsx b/application/shared-webapp/infrastructure/auth/AuthenticationProvider.tsx index 57dae0a95..f45d46210 100644 --- a/application/shared-webapp/infrastructure/auth/AuthenticationProvider.tsx +++ b/application/shared-webapp/infrastructure/auth/AuthenticationProvider.tsx @@ -1,7 +1,9 @@ import type { NavigateOptions } from "@tanstack/react-router"; import type React from "react"; -import { createContext, useCallback, useMemo, useState } from "react"; +import { createContext, useCallback, useEffect, useMemo, useState } from "react"; + +import { subscribeToUserFeatureFlags } from "../featureFlags/userFeatureFlagsHeader"; export type UserInfo = { initials: string; @@ -35,6 +37,19 @@ export function AuthenticationProvider({ children }: Readonly { + return subscribeToUserFeatureFlags((nextFlagKeys) => { + setUserInfo((prevUserInfo) => { + if (prevUserInfo === null) return prevUserInfo; + if (sameFlagKeys(prevUserInfo.featureFlags ?? [], nextFlagKeys)) return prevUserInfo; + return createUserInfo({ ...prevUserInfo, featureFlags: nextFlagKeys }); + }); + }); + }, []); + const authenticationContext = useMemo( () => ({ userInfo, @@ -46,6 +61,15 @@ export function AuthenticationProvider({ children }: Readonly{children}; } +function sameFlagKeys(previous: string[], next: string[]): boolean { + if (previous.length !== next.length) return false; + const previousSet = new Set(previous); + for (const key of next) { + if (!previousSet.has(key)) return false; + } + return true; +} + function createUserInfo(userInfoEnv: UserInfoEnv): UserInfo { const { firstName, lastName, email } = userInfoEnv; const getInitial = (name: string | undefined) => name?.[0] ?? ""; diff --git a/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts b/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts index 78637831e..d14191349 100644 --- a/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts +++ b/application/shared-webapp/infrastructure/featureFlags/useFeatureFlag.ts @@ -1,3 +1,4 @@ +import { useUserInfo } from "../auth/hooks"; import { getFlag } from "./registry"; type FeatureFlagResult = { enabled: boolean; isLoading: boolean }; @@ -6,6 +7,10 @@ const DISABLED: FeatureFlagResult = { enabled: false, isLoading: false }; const ENABLED: FeatureFlagResult = { enabled: true, isLoading: false }; export function useFeatureFlag(flagKey: string): FeatureFlagResult { + // Read on every render so re-renders triggered by AuthenticationProvider state updates pick up + // the new flag set without a page reload. + const userInfo = useUserInfo(); + const definition = getFlag(flagKey); if (!definition) return DISABLED; @@ -14,8 +19,7 @@ export function useFeatureFlag(flagKey: string): FeatureFlagResult { return import.meta.runtime_env[envVar] === "true" ? ENABLED : DISABLED; } - const userInfo = import.meta.user_info_env; - if (!userInfo.isAuthenticated) return DISABLED; + if (!userInfo?.isAuthenticated) return DISABLED; const enabledFlags = userInfo.featureFlags ?? []; return enabledFlags.includes(flagKey) ? ENABLED : DISABLED; diff --git a/application/shared-webapp/infrastructure/featureFlags/userFeatureFlagsHeader.ts b/application/shared-webapp/infrastructure/featureFlags/userFeatureFlagsHeader.ts new file mode 100644 index 000000000..4f3426ced --- /dev/null +++ b/application/shared-webapp/infrastructure/featureFlags/userFeatureFlagsHeader.ts @@ -0,0 +1,38 @@ +/** + * Bridge between the fetch layer (no React) and AuthenticationProvider (React). + * + * AppGateway emits `x-user-feature-flags` on every authenticated response with the comma-separated + * keys of database-scoped flags currently enabled for the user. Fetch interceptors call + * `dispatchUserFeatureFlagsFromResponse(response)`; AuthenticationProvider subscribes on mount and + * diffs the keys against its state, updating only on change. + */ + +export const USER_FEATURE_FLAGS_HEADER = "x-user-feature-flags"; + +type Listener = (flagKeys: string[]) => void; + +const listeners = new Set(); + +function parseHeader(value: string): string[] { + return value + .split(",") + .map((key) => key.trim()) + .filter((key) => key.length > 0); +} + +export function dispatchUserFeatureFlagsFromResponse(response: Response): void { + const headerValue = response.headers.get(USER_FEATURE_FLAGS_HEADER); + if (headerValue === null) return; + + const flagKeys = parseHeader(headerValue); + for (const listener of listeners) { + listener(flagKeys); + } +} + +export function subscribeToUserFeatureFlags(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/application/shared-webapp/infrastructure/http/httpClient.ts b/application/shared-webapp/infrastructure/http/httpClient.ts index 3b69170b3..f757f1659 100644 --- a/application/shared-webapp/infrastructure/http/httpClient.ts +++ b/application/shared-webapp/infrastructure/http/httpClient.ts @@ -9,6 +9,7 @@ * Use this module when working with endpoints that don't have OpenAPI/strongly-typed definitions */ import { getHasPendingAuthSync } from "../auth/AuthSyncService"; +import { dispatchUserFeatureFlagsFromResponse } from "../featureFlags/userFeatureFlagsHeader"; import { normalizeError } from "./errorHandler"; /** @@ -59,6 +60,8 @@ export async function enhancedFetch(input: RequestInfo | URL, init?: RequestInit try { const response = await window.fetch(input, enhancedInit); + dispatchUserFeatureFlagsFromResponse(response); + if (!response.ok) { throw await normalizeError(response); } diff --git a/application/shared-webapp/infrastructure/http/queryClient.ts b/application/shared-webapp/infrastructure/http/queryClient.ts index 5cdc0b56c..30f0119eb 100644 --- a/application/shared-webapp/infrastructure/http/queryClient.ts +++ b/application/shared-webapp/infrastructure/http/queryClient.ts @@ -16,6 +16,7 @@ import createClient from "openapi-react-query"; import { createAuthenticationMiddleware } from "../auth/AuthenticationMiddleware"; import { getHasPendingAuthSync } from "../auth/AuthSyncService"; +import { dispatchUserFeatureFlagsFromResponse } from "../featureFlags/userFeatureFlagsHeader"; import { preferredLocaleKey } from "../translations/constants"; import { type HttpError, normalizeError } from "./errorHandler"; import { DEFAULT_TIMEOUT } from "./httpClient"; @@ -71,6 +72,8 @@ function createHttpMiddleware() { return new Request(request, { signal }); }, onResponse: async ({ response }: { request: Request; response: Response }) => { + dispatchUserFeatureFlagsFromResponse(response); + if (!response.ok) { // Normalize error and re-throw, so failed requests are handled via error handling throw await normalizeError(response); From f4c40e26e21351641d7218cd6d670a746dc69657 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 19:34:40 +0200 Subject: [PATCH 059/155] Emit x-user-feature-flags via YARP response transform and complete cookie swap in middleware --- .../UserFeatureFlagsResponseTransformTests.cs | 92 +++++++++++++++++++ .../AuthenticationCookieMiddleware.cs | 21 +---- application/AppGateway/Program.cs | 4 +- .../UserFeatureFlagsResponseTransform.cs | 39 ++++++++ 4 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs create mode 100644 application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs diff --git a/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs b/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs new file mode 100644 index 000000000..fe9b554cd --- /dev/null +++ b/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using AppGateway.Transformations; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using SharedKernel.Authentication; +using SharedKernel.Authentication.TokenSigning; +using Xunit; +using Yarp.ReverseProxy.Transforms; + +namespace AppGateway.Tests; + +public sealed class UserFeatureFlagsResponseTransformTests(AppGatewayApplicationFactory factory) : IClassFixture +{ + [Fact] + public async Task ApplyAsync_WhenAccessTokenStashedWithFeatureFlagsClaim_ShouldEmitHeaderWithKeys() + { + // Arrange + var transform = factory.Services.GetRequiredService(); + var signingClient = factory.Services.GetRequiredService(); + var accessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "custom-branding,compact-view")]); + + var context = new DefaultHttpContext + { + Response = { Body = new MemoryStream() }, + Items = { [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken } + }; + var transformContext = new ResponseTransformContext { HttpContext = context }; + + // Act + await transform.ApplyAsync(transformContext); + + // Assert + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("custom-branding,compact-view"); + } + + [Fact] + public async Task ApplyAsync_WhenAccessTokenHasNoFeatureFlagsClaim_ShouldEmitEmptyHeader() + { + // Arrange + var transform = factory.Services.GetRequiredService(); + var signingClient = factory.Services.GetRequiredService(); + var accessToken = CreateSignedToken(signingClient, []); + + var context = new DefaultHttpContext + { + Response = { Body = new MemoryStream() }, + Items = { [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken } + }; + var transformContext = new ResponseTransformContext { HttpContext = context }; + + // Act + await transform.ApplyAsync(transformContext); + + // Assert + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be(string.Empty); + } + + [Fact] + public async Task ApplyAsync_WhenNoAccessTokenStashed_ShouldNotEmitHeader() + { + // Arrange + var transform = factory.Services.GetRequiredService(); + + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + var transformContext = new ResponseTransformContext { HttpContext = context }; + + // Act + await transform.ApplyAsync(transformContext); + + // Assert + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); + } + + private static string CreateSignedToken(ITokenSigningClient signingClient, Claim[] claims) + { + var now = DateTimeOffset.UtcNow; + var descriptor = new SecurityTokenDescriptor + { + NotBefore = now.UtcDateTime, + IssuedAt = now.UtcDateTime, + Expires = now.AddMinutes(5).UtcDateTime, + Issuer = signingClient.Issuer, + Audience = signingClient.Audience, + SigningCredentials = signingClient.GetSigningCredentials(), + Subject = new ClaimsIdentity(claims) + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index d65e7048b..c6bc2593b 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using AppGateway.Transformations; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using SharedKernel.Authentication; @@ -16,7 +17,6 @@ ILogger logger { private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account/authentication/refresh-authentication-tokens"; private const string UnauthorizedReasonItemKey = "UnauthorizedReason"; - private const string CurrentAccessTokenItemKey = "CurrentAccessToken"; private static readonly JsonWebTokenHandler TokenHandler = new(); @@ -71,14 +71,6 @@ async Task HandleRefreshAsync() { await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); } - - // Emit x-user-feature-flags on every authenticated response so the SPA can converge on the - // current set of enabled flags without polling. Value reflects the access token in effect - // after any refresh that just happened. - if (context.Items.TryGetValue(CurrentAccessTokenItemKey, out var tokenItem) && tokenItem is string currentAccessToken) - { - context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(currentAccessToken); - } } // Register OnStarting BEFORE next(context). YARP streams responses back, so by the time control @@ -127,7 +119,7 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http } context.Request.Headers.Authorization = $"Bearer {accessToken}"; - context.Items[CurrentAccessTokenItemKey] = accessToken; + context.Items[UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken; } catch (SessionRevokedException ex) { @@ -244,14 +236,7 @@ private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext contex context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); - context.Items[CurrentAccessTokenItemKey] = accessToken; - } - - private static string ExtractFeatureFlagsClaim(string accessToken) - { - if (!TokenHandler.CanReadToken(accessToken)) return string.Empty; - var jwt = TokenHandler.ReadJsonWebToken(accessToken); - return jwt.TryGetClaim("feature_flags", out var claim) ? claim.Value : string.Empty; + context.Items[UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken; } private async Task ExtractExpirationFromTokenAsync(string token) diff --git a/application/AppGateway/Program.cs b/application/AppGateway/Program.cs index ef848386a..83ab13ef8 100644 --- a/application/AppGateway/Program.cs +++ b/application/AppGateway/Program.cs @@ -38,7 +38,8 @@ .AddConfigFilter() .AddConfigFilter() .AddConfigFilter() - .AddTransforms(context => context.RequestTransforms.Add(context.Services.GetRequiredService())); + .AddTransforms(context => context.RequestTransforms.Add(context.Services.GetRequiredService())) + .AddTransforms(context => context.ResponseTransforms.Add(context.Services.GetRequiredService())); if (SharedInfrastructureConfiguration.IsRunningInAzure) { @@ -86,6 +87,7 @@ builder.Services .AddSingleton(SharedDependencyConfiguration.GetTokenSigningService()) .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddScoped(); diff --git a/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs b/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs new file mode 100644 index 000000000..8c6a40423 --- /dev/null +++ b/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs @@ -0,0 +1,39 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using SharedKernel.Authentication; +using Yarp.ReverseProxy.Transforms; + +namespace AppGateway.Transformations; + +/// +/// Emits the `x-user-feature-flags` response header on every authenticated YARP response, with +/// the comma-separated feature-flag keys from the (post-refresh) access-token claim. The SPA +/// diffs the value against its current state and updates only on change. +/// Implemented as a YARP `ResponseTransform` rather than an `OnStarting` callback because +/// `OnStarting` was empirically unreliable for the YARP-proxied path - the callback did not +/// fire in time to mutate response headers before they were flushed to the client. +/// `ResponseTransform.ApplyAsync` runs after the upstream response is received and before +/// YARP calls `Response.StartAsync`, which is a guaranteed mutation point. +/// +public sealed class UserFeatureFlagsResponseTransform : ResponseTransform +{ + public const string CurrentAccessTokenItemKey = "CurrentAccessToken"; + + private static readonly JsonWebTokenHandler TokenHandler = new(); + + public override ValueTask ApplyAsync(ResponseTransformContext context) + { + if (context.HttpContext.Items.TryGetValue(CurrentAccessTokenItemKey, out var tokenItem) && tokenItem is string accessToken) + { + context.HttpContext.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(accessToken); + } + + return ValueTask.CompletedTask; + } + + private static string ExtractFeatureFlagsClaim(string accessToken) + { + if (!TokenHandler.CanReadToken(accessToken)) return string.Empty; + var jwt = TokenHandler.ReadJsonWebToken(accessToken); + return jwt.TryGetClaim("feature_flags", out var claim) ? claim.Value : string.Empty; + } +} From ad8b2dd3d8df8e4940fce95c229c5cbd6bb9bab6 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 20:04:53 +0200 Subject: [PATCH 060/155] Consolidate cookie-swap into YARP response transform to close race with feature-flag header --- .../AuthenticationCookiePathTests.cs | 23 +-- .../UserFeatureFlagsResponseTransformTests.cs | 121 ++++++++++++ .../AuthenticationCookieMiddleware.cs | 42 +--- .../UserFeatureFlagsResponseTransform.cs | 182 ++++++++++++++++-- 4 files changed, 302 insertions(+), 66 deletions(-) diff --git a/application/AppGateway.Tests/AuthenticationCookiePathTests.cs b/application/AppGateway.Tests/AuthenticationCookiePathTests.cs index bd1cba95e..2ecec297c 100644 --- a/application/AppGateway.Tests/AuthenticationCookiePathTests.cs +++ b/application/AppGateway.Tests/AuthenticationCookiePathTests.cs @@ -1,4 +1,5 @@ using AppGateway.Middleware; +using AppGateway.Transformations; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -7,17 +8,19 @@ using SharedKernel.Authentication; using SharedKernel.Authentication.TokenSigning; using Xunit; +using Yarp.ReverseProxy.Transforms; namespace AppGateway.Tests; public sealed class AuthenticationCookiePathTests(AppGatewayApplicationFactory factory) : IClassFixture { [Fact] - public async Task ReplaceAuthenticationHeaderWithCookie_WhenInnerHandlerSetsTokenHeaders_ShouldIssueHostCookiesWithPathSlash() + public async Task ApplyAsync_WhenUpstreamResponseCarriesTokenHeaders_ShouldIssueHostCookiesWithPathSlash() { - // Arrange + // Login / signup / switch-tenant flows return the new tokens in response headers; the YARP + // response transform converts them to __Host- cookies with Path=/ before egress. await using var scope = factory.Services.CreateAsyncScope(); - var middleware = scope.ServiceProvider.GetRequiredService(); + var transform = scope.ServiceProvider.GetRequiredService(); var signingClient = scope.ServiceProvider.GetRequiredService(); var refreshToken = CreateSignedToken(signingClient, 60); @@ -28,18 +31,12 @@ public async Task ReplaceAuthenticationHeaderWithCookie_WhenInnerHandlerSetsToke Request = { Path = "/api/account/authentication/switch-tenant" }, Response = { Body = new MemoryStream() } }; + context.Response.Headers[AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey] = refreshToken; + context.Response.Headers[AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey] = accessToken; + var transformContext = new ResponseTransformContext { HttpContext = context }; - Task Next(HttpContext ctx) - { - ctx.Response.Headers[AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey] = refreshToken; - ctx.Response.Headers[AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey] = accessToken; - return Task.CompletedTask; - } + await transform.ApplyAsync(transformContext); - // Act - await middleware.InvokeAsync(context, Next); - - // Assert var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.RefreshTokenCookieName)); setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.AccessTokenCookieName)); diff --git a/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs b/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs index fe9b554cd..5098d0afc 100644 --- a/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs +++ b/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs @@ -1,8 +1,12 @@ +using System.Net; using System.Security.Claims; using AppGateway.Transformations; using FluentAssertions; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using SharedKernel.Authentication; @@ -74,6 +78,78 @@ public async Task ApplyAsync_WhenNoAccessTokenStashed_ShouldNotEmitHeader() context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); } + [Fact] + public async Task ApplyAsync_WhenUpstreamSetsRefreshAuthenticationTokensHeader_ShouldRefreshAndEmitPostRefreshHeader() + { + // Regression net for the prior split-design race: the transform read the pre-refresh access + // token before OnStarting swapped cookies, so the response header reflected stale flag state. + // Consolidating cookie-swap into the transform must produce a header that matches the + // post-refresh claim set on the SAME response. + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var transform = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + + var inboundRefreshToken = CreateSignedToken(signingClient, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "stale-flag")]); + var postRefreshAccessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "custom-branding")]); + var postRefreshRefreshToken = CreateSignedToken(signingClient, []); + RefreshStubAppGatewayApplicationFactory.SetStubResponse(postRefreshRefreshToken, postRefreshAccessToken); + + var context = new DefaultHttpContext + { + Response = { Body = new MemoryStream() }, + Items = + { + [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = preRefreshAccessToken, + [UserFeatureFlagsResponseTransform.InboundRefreshTokenItemKey] = inboundRefreshToken + } + }; + context.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + var transformContext = new ResponseTransformContext { HttpContext = context }; + + await transform.ApplyAsync(transformContext); + + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("custom-branding"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); + setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.RefreshTokenCookieName)); + setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.AccessTokenCookieName)); + } + + [Fact] + public async Task ApplyAsync_WhenRefreshEndpointSignalsSessionRevoked_ShouldOverwriteResponseWith401AndClearCookies() + { + // The refresh endpoint can detect a revoked session (e.g. token replay) and return 401 with + // x-unauthorized-reason. The transform must surface that to the SPA, not silently let the + // upstream success bleed through with the original cookies still valid. + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var transform = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + + var inboundRefreshToken = CreateSignedToken(signingClient, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "stale-flag")]); + RefreshStubAppGatewayApplicationFactory.SetStubRevoked("ReplayAttackDetected"); + + var context = new DefaultHttpContext + { + Response = { Body = new MemoryStream(), StatusCode = StatusCodes.Status200OK }, + Items = + { + [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = preRefreshAccessToken, + [UserFeatureFlagsResponseTransform.InboundRefreshTokenItemKey] = inboundRefreshToken + } + }; + context.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + var transformContext = new ResponseTransformContext { HttpContext = context }; + + await transform.ApplyAsync(transformContext); + + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey].ToString().Should().Be("ReplayAttackDetected"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + private static string CreateSignedToken(ITokenSigningClient signingClient, Claim[] claims) { var now = DateTimeOffset.UtcNow; @@ -90,3 +166,48 @@ private static string CreateSignedToken(ITokenSigningClient signingClient, Claim return new JsonWebTokenHandler().CreateToken(descriptor); } } + +internal sealed class RefreshStubAppGatewayApplicationFactory : WebApplicationFactory +{ + private static string _refreshToken = string.Empty; + private static string _accessToken = string.Empty; + private static string? _revokedReason; + + public static void SetStubResponse(string refreshToken, string accessToken) + { + _refreshToken = refreshToken; + _accessToken = accessToken; + _revokedReason = null; + } + + public static void SetStubRevoked(string revokedReason) + { + _revokedReason = revokedReason; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); + builder.ConfigureServices(services => { services.AddHttpClient("Account").ConfigurePrimaryHttpMessageHandler(() => new StubRefreshHandler()); } + ); + } + + private sealed class StubRefreshHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_revokedReason is not null) + { + var revoked = new HttpResponseMessage(HttpStatusCode.Unauthorized); + revoked.Headers.Add(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, _revokedReason); + return Task.FromResult(revoked); + } + + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Headers.Add(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, _refreshToken); + response.Headers.Add(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, _accessToken); + return Task.FromResult(response); + } + } +} diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index c6bc2593b..7238fa82d 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -22,12 +22,11 @@ ILogger logger public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - string? refreshTokenCookieValue = null; if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenFromCookie)) { - refreshTokenCookieValue = refreshTokenFromCookie; + context.Items[UserFeatureFlagsResponseTransform.InboundRefreshTokenItemKey] = refreshTokenFromCookie; context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenCookieName, out var accessTokenCookieValue); - await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenCookieValue, accessTokenCookieValue); + await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenFromCookie, accessTokenCookieValue); } // If session was revoked during refresh, handle based on request type @@ -48,44 +47,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } - var refreshHandled = false; - - async Task HandleRefreshAsync() - { - if (refreshHandled) return; - refreshHandled = true; - - if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) - { - if (refreshTokenCookieValue is not null) - { - logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); - var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue); - await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); - } - - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); - } - else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && - context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) - { - await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); - } - } - - // Register OnStarting BEFORE next(context). YARP streams responses back, so by the time control - // returns from next(context) the response headers have already been flushed and Set-Cookie can - // no longer be appended. OnStarting fires while headers are still mutable. The fallback call - // after next(context) handles direct invocation paths (e.g. unit tests) where OnStarting is - // never triggered because the response is never sent. - context.Response.OnStarting(HandleRefreshAsync); - await next(context); - - if (!context.Response.HasStarted) - { - await HandleRefreshAsync(); - } } private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(HttpContext context, string refreshToken, string? accessToken) diff --git a/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs b/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs index 8c6a40423..26bbe01c0 100644 --- a/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs +++ b/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs @@ -1,33 +1,171 @@ +using System.Net; +using System.Net.Http.Headers; +using AppGateway.Middleware; using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; using SharedKernel.Authentication; +using SharedKernel.Authentication.TokenSigning; using Yarp.ReverseProxy.Transforms; namespace AppGateway.Transformations; /// -/// Emits the `x-user-feature-flags` response header on every authenticated YARP response, with -/// the comma-separated feature-flag keys from the (post-refresh) access-token claim. The SPA -/// diffs the value against its current state and updates only on change. -/// Implemented as a YARP `ResponseTransform` rather than an `OnStarting` callback because -/// `OnStarting` was empirically unreliable for the YARP-proxied path - the callback did not -/// fire in time to mutate response headers before they were flushed to the client. -/// `ResponseTransform.ApplyAsync` runs after the upstream response is received and before -/// YARP calls `Response.StartAsync`, which is a guaranteed mutation point. +/// YARP response transform that consolidates the post-upstream authentication response handling: +/// swap any header-mode refresh/access tokens into cookies, perform the endpoint-triggered token +/// refresh when the upstream sets x-refresh-authentication-tokens-required, and emit +/// x-user-feature-flags from the (post-refresh) access-token claim. +/// A single hook eliminates the race the prior split design had: a separate OnStarting callback +/// refreshed the cookies AFTER this transform had already read the pre-refresh access token, +/// causing the actor's same-response header to reflect stale flag state. The transform runs +/// after the upstream response is received and before YARP calls Response.StartAsync, +/// so all mutations land on the same outgoing response. /// -public sealed class UserFeatureFlagsResponseTransform : ResponseTransform +public sealed class UserFeatureFlagsResponseTransform( + IHttpClientFactory httpClientFactory, + ITokenSigningClient tokenSigningClient, + ILogger logger +) : ResponseTransform { public const string CurrentAccessTokenItemKey = "CurrentAccessToken"; + public const string InboundRefreshTokenItemKey = "InboundRefreshToken"; + + private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account/authentication/refresh-authentication-tokens"; private static readonly JsonWebTokenHandler TokenHandler = new(); - public override ValueTask ApplyAsync(ResponseTransformContext context) + public override async ValueTask ApplyAsync(ResponseTransformContext context) + { + var httpContext = context.HttpContext; + + // Endpoint-triggered refresh: the downstream signaled the actor's claims have changed + // (e.g. PUT /me, PUT /me/change-locale, PUT /api/account/feature-flags/{key}/tenant-override). + // Refresh the JWT now so the same response carries fresh cookies AND a fresh x-user-feature-flags. + if (httpContext.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) + { + if (httpContext.Items.TryGetValue(InboundRefreshTokenItemKey, out var refreshItem) && refreshItem is string inboundRefreshToken) + { + try + { + logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); + var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(inboundRefreshToken); + await ReplaceAuthenticationHeaderWithCookieAsync(httpContext, refreshToken, accessToken); + } + catch (SessionRevokedException ex) + { + OverwriteWithUnauthorized(httpContext, ex.RevokedReason); + logger.LogWarning(ex, "Session revoked during endpoint-triggered refresh. Reason: {Reason}", ex.RevokedReason); + return; + } + catch (SecurityTokenException ex) + { + OverwriteWithUnauthorized(httpContext, nameof(UnauthorizedReason.SessionNotFound)); + logger.LogWarning(ex, "Endpoint-triggered token refresh failed validation. Path: {Path}", httpContext.Request.Path); + return; + } + catch (HttpRequestException ex) + { + // Backend temporarily unreachable: the upstream mutation already succeeded, so + // let the response through. The SPA picks up new claims on the next refresh. + logger.LogWarning(ex, "Backend unavailable during endpoint-triggered refresh. Path: {Path}", httpContext.Request.Path); + } + catch (TaskCanceledException ex) when (!httpContext.RequestAborted.IsCancellationRequested) + { + logger.LogWarning(ex, "Backend timed out during endpoint-triggered refresh. Path: {Path}", httpContext.Request.Path); + } + } + + httpContext.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + else if (httpContext.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && + httpContext.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) + { + // Login / signup / switch-tenant flows return the new tokens in response headers + // for AppGateway to convert into cookies before egress. + await ReplaceAuthenticationHeaderWithCookieAsync(httpContext, refreshToken.Single()!, accessToken.Single()!); + } + + // Emit x-user-feature-flags from the current access token (post-refresh if a refresh just + // happened above, else the inbound token stashed by AuthenticationCookieMiddleware). + if (httpContext.Items.TryGetValue(CurrentAccessTokenItemKey, out var tokenItem) && tokenItem is string currentAccessToken) + { + httpContext.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(currentAccessToken); + } + } + + private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, RefreshAuthenticationTokensEndpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", refreshToken); + + var accountHttpClient = httpClientFactory.CreateClient("Account"); + var response = await accountHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + if (response.Headers.TryGetValues(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, out var reasons) && reasons.FirstOrDefault() is { } revokedReason) + { + throw new SessionRevokedException(revokedReason); + } + + if (response.StatusCode is HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout) + { + throw new HttpRequestException($"Backend temporarily unavailable. Status: {response.StatusCode}.", null, response.StatusCode); + } + + throw new SecurityTokenException($"Failed to refresh security tokens. Response status code: {response.StatusCode}."); + } + + var newRefreshToken = response.Headers.GetValues(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey).SingleOrDefault(); + var newAccessToken = response.Headers.GetValues(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey).SingleOrDefault(); + + if (newRefreshToken is null || newAccessToken is null) + { + throw new SecurityTokenException("Failed to get refreshed security tokens from the response."); + } + + return (newRefreshToken, newAccessToken); + } + + private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext context, string refreshToken, string accessToken) + { + var refreshTokenExpires = await ExtractExpirationFromTokenAsync(refreshToken); + + var refreshTokenCookieOptions = new CookieOptions + { + HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, Expires = refreshTokenExpires, Path = "/" + }; + context.Response.Cookies.Append(AuthenticationTokenHttpKeys.RefreshTokenCookieName, refreshToken, refreshTokenCookieOptions); + + var accessTokenCookieOptions = new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Path = "/" }; + context.Response.Cookies.Append(AuthenticationTokenHttpKeys.AccessTokenCookieName, accessToken, accessTokenCookieOptions); + + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); + + context.Items[CurrentAccessTokenItemKey] = accessToken; + } + + private async Task ExtractExpirationFromTokenAsync(string token) { - if (context.HttpContext.Items.TryGetValue(CurrentAccessTokenItemKey, out var tokenItem) && tokenItem is string accessToken) + if (!TokenHandler.CanReadToken(token)) { - context.HttpContext.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(accessToken); + throw new SecurityTokenMalformedException("The token is not a valid JWT."); } - return ValueTask.CompletedTask; + var validationParameters = tokenSigningClient.GetTokenValidationParameters( + validateLifetime: false, + clockSkew: TimeSpan.FromSeconds(2) + ); + + var validationResult = await TokenHandler.ValidateTokenAsync(token, validationParameters); + + if (!validationResult.IsValid) + { + throw validationResult.Exception; + } + + var expires = validationResult.Claims[JwtRegisteredClaimNames.Exp]?.ToString()!; + return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)); } private static string ExtractFeatureFlagsClaim(string accessToken) @@ -36,4 +174,22 @@ private static string ExtractFeatureFlagsClaim(string accessToken) var jwt = TokenHandler.ReadJsonWebToken(accessToken); return jwt.TryGetClaim("feature_flags", out var claim) ? claim.Value : string.Empty; } + + /// + /// Convert the upstream success response into a 401 with x-unauthorized-reason so the + /// SPA's authentication middleware can react (redirect to login, surface revocation reason). + /// Mirrors the pre-consolidation behavior in . + /// + private static void OverwriteWithUnauthorized(HttpContext context, string unauthorizedReason) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = unauthorizedReason; + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); + + var hostCookieOptions = new CookieOptions { Secure = true, Path = "/" }; + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); + } } From 78e220fa5e89ac55c02c886a1043dc464ded4162 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 20:59:16 +0200 Subject: [PATCH 061/155] Update e2e for x-user-feature-flags propagation --- .../tests/e2e/feature-flag-flows.spec.ts | 193 ++++++++++++++---- 1 file changed, 157 insertions(+), 36 deletions(-) diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 7534f7153..e05439c6b 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -1,4 +1,4 @@ -import { expect } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; import { getBackOfficeBaseUrl } from "@shared/e2e/utils/constants"; import { blurActiveElement, createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; @@ -6,6 +6,17 @@ import { step } from "@shared/e2e/utils/test-step-wrapper"; const BACK_OFFICE_BASE_URL = getBackOfficeBaseUrl(); +// SPA shells inject the antiforgery token into a `` tag at runtime. +// Back-office mutation endpoints removed `.DisableAntiforgery()`, so Playwright API calls now have to +// send `x-xsrf-token` just like the SPA's fetch middleware does. The antiforgery cookie ships with +// the page context automatically. +async function getAntiforgeryHeaders(page: Page): Promise<{ "x-xsrf-token": string }> { + const token = await page.evaluate( + () => document.head.querySelector('meta[name="antiforgeryToken"]')?.getAttribute("content") ?? "" + ); + return { "x-xsrf-token": token }; +} + test.describe("@smoke", () => { /** * FEATURE FLAG SYSTEM E2E TEST @@ -15,6 +26,9 @@ test.describe("@smoke", () => { * - Back-office flag detail: navigate into account-scoped flag, search by name, toggle override pair, set A/B rollout percentage * - Account settings: verify Features section, toggle account-scoped custom branding flag * - User preferences: verify Beta features section, toggle user-scoped compact view flag + * - x-user-feature-flags propagation: AppGateway emits the header on every authenticated response; + * owner and user self-toggles trigger AddRefreshAuthenticationTokens so the same response cycle + * already carries the updated flag set (no waiting for the next 5-min JWT refresh boundary). */ test("should manage feature flags across back-office, account settings & user preferences", async ({ ownerPage, @@ -69,31 +83,49 @@ test.describe("@smoke", () => { await step("Pin beta-features rollout to 100 via back-office API & verify tenants evaluate enabled")(async () => { const response = await page.request.put( `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, - { data: { rolloutPercentage: 100 } } + { data: { rolloutPercentage: 100 }, headers: await getAntiforgeryHeaders(page) } ); expect(response.ok()).toBe(true); })(); await step( - "Search by Test Organization & verify the URL reflects the debounced search term and the table re-renders" + "Switch to the All state filter and search by Test Organization & verify a matching row renders" )(async () => { + // Switch to the All filter so the row set is independent of cross-worker rollout-percentage races + // on the shared beta-features global state. Without this, parallel test runs from other browser + // workers can flip rollout to 0 mid-test, emptying the default Enabled view. + await page.getByRole("group", { name: "State" }).getByRole("button", { name: "All" }).click(); await page.getByRole("searchbox", { name: "Search" }).fill("Test Organization"); + // Wait for the search-debounce query to settle AND for a matching row to render. Without the row + // assertion, downstream `.first()` targeting can bind to a row from a parallel test's tenant + // ("Mobile Nav Test" etc.) that happens to slip through the search match, or to a stale row + // from the pre-debounce result set that becomes detached when the new query resolves. await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); - await expect(page.getByRole("table", { name: "Accounts" })).toBeVisible(); + await expect( + page.getByRole("table", { name: "Accounts" }).getByRole("row").filter({ hasText: "Test Organization" }).first() + ).toBeVisible(); })(); - await step("Toggle the first account override & verify toast confirms state change")(async () => { - const overrideSwitch = page.getByRole("table", { name: "Accounts" }).getByRole("switch").first(); - await overrideSwitch.click(); + await step("Toggle the first Test Organization override & verify toast confirms state change")(async () => { + const testOrgRow = page + .getByRole("table", { name: "Accounts" }) + .getByRole("row") + .filter({ hasText: "Test Organization" }) + .first(); + await testOrgRow.getByRole("switch").click(); await expectToastMessage(context, "Beta features"); })(); - await step("Toggle the same account override back & verify toast confirms state change")(async () => { - const overrideSwitch = page.getByRole("table", { name: "Accounts" }).getByRole("switch").first(); - await overrideSwitch.click(); + await step("Toggle the same Test Organization override back & verify toast confirms state change")(async () => { + const testOrgRow = page + .getByRole("table", { name: "Accounts" }) + .getByRole("row") + .filter({ hasText: "Test Organization" }) + .first(); + await testOrgRow.getByRole("switch").click(); await expectToastMessage(context, "Beta features"); })(); @@ -115,7 +147,8 @@ test.describe("@smoke", () => { await step("Activate custom branding flag globally via back-office API for downstream checks")(async () => { const response = await page.request.put( - `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/custom-branding/activate` + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/custom-branding/activate`, + { headers: await getAntiforgeryHeaders(page) } ); expect(response.ok()).toBe(true); @@ -123,7 +156,8 @@ test.describe("@smoke", () => { await step("Activate compact view flag globally via back-office API for downstream checks")(async () => { const response = await page.request.put( - `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/compact-view/activate` + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/compact-view/activate`, + { headers: await getAntiforgeryHeaders(page) } ); expect(response.ok()).toBe(true); @@ -140,12 +174,43 @@ test.describe("@smoke", () => { await expect(ownerPage.getByText("Custom branding")).toBeVisible(); })(); - await step("Toggle custom branding flag & verify success toast")(async () => { - const toggle = ownerPage.getByRole("switch", { name: "Custom branding" }); - await toggle.click(); + await step("Toggle custom branding flag ON & verify response carries x-user-feature-flags with custom-branding")( + async () => { + const toggle = ownerPage.getByRole("switch", { name: "Custom branding" }); - await expectToastMessage(ownerContext, "Feature updated"); - })(); + const [tenantOverrideResponse] = await Promise.all([ + ownerPage.waitForResponse( + (response) => + response.url().includes("/api/account/feature-flags/custom-branding/tenant-override") && + response.request().method() === "PUT" + ), + toggle.click() + ]); + + expect(tenantOverrideResponse.headers()["x-user-feature-flags"]).toContain("custom-branding"); + + await expectToastMessage(ownerContext, "Feature updated"); + } + )(); + + await step("Toggle custom branding flag OFF & verify response x-user-feature-flags no longer contains it")( + async () => { + const toggle = ownerPage.getByRole("switch", { name: "Custom branding" }); + + const [tenantOverrideResponse] = await Promise.all([ + ownerPage.waitForResponse( + (response) => + response.url().includes("/api/account/feature-flags/custom-branding/tenant-override") && + response.request().method() === "PUT" + ), + toggle.click() + ]); + + expect(tenantOverrideResponse.headers()["x-user-feature-flags"]).not.toContain("custom-branding"); + + await expectToastMessage(ownerContext, "Feature updated"); + } + )(); // === USER PREFERENCES: USER FEATURE FLAGS === @@ -156,12 +221,43 @@ test.describe("@smoke", () => { await expect(ownerPage.getByText("Compact view")).toBeVisible(); })(); - await step("Toggle compact view user flag & verify success toast")(async () => { - const toggle = ownerPage.getByRole("switch", { name: "Compact view" }); - await toggle.click(); + await step("Toggle compact view user flag ON & verify response carries x-user-feature-flags with compact-view")( + async () => { + const toggle = ownerPage.getByRole("switch", { name: "Compact view" }); - await expectToastMessage(ownerContext, "Preference updated"); - })(); + const [userOverrideResponse] = await Promise.all([ + ownerPage.waitForResponse( + (response) => + response.url().includes("/api/account/feature-flags/compact-view/user-override") && + response.request().method() === "PUT" + ), + toggle.click() + ]); + + expect(userOverrideResponse.headers()["x-user-feature-flags"]).toContain("compact-view"); + + await expectToastMessage(ownerContext, "Preference updated"); + } + )(); + + await step("Toggle compact view flag OFF & verify response x-user-feature-flags no longer contains it")( + async () => { + const toggle = ownerPage.getByRole("switch", { name: "Compact view" }); + + const [userOverrideResponse] = await Promise.all([ + ownerPage.waitForResponse( + (response) => + response.url().includes("/api/account/feature-flags/compact-view/user-override") && + response.request().method() === "PUT" + ), + toggle.click() + ]); + + expect(userOverrideResponse.headers()["x-user-feature-flags"]).not.toContain("compact-view"); + + await expectToastMessage(ownerContext, "Preference updated"); + } + )(); }); }); @@ -200,7 +296,7 @@ test.describe("@comprehensive", () => { await step("Pin beta-features rollout to 100 via back-office API & verify tenants evaluate enabled")(async () => { const response = await page.request.put( `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, - { data: { rolloutPercentage: 100 } } + { data: { rolloutPercentage: 100 }, headers: await getAntiforgeryHeaders(page) } ); expect(response.ok()).toBe(true); @@ -277,10 +373,23 @@ test.describe("@comprehensive", () => { await step("Type into search box & verify URL contains debounced search term and the table re-renders")( async () => { + // Re-pin rollout=100 so cross-browser parallel runs that may have reset rollout to 0 during + // their cleanup can't empty the default Enabled view out from under this step's assertion. + // Reload so the tenants query refetches with the new rollout in effect (the prior cached + // result from before this re-pin would otherwise win until natural invalidation). + const rolloutResponse = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, + { data: { rolloutPercentage: 100 }, headers: await getAntiforgeryHeaders(page) } + ); + expect(rolloutResponse.ok()).toBe(true); + + await page.reload(); await searchBox.fill("Test Organization"); await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); - await expect(accountsTable).toBeVisible(); + await expect( + accountsTable.getByRole("row").filter({ hasText: "Test Organization" }).first() + ).toBeVisible(); } )(); @@ -311,19 +420,31 @@ test.describe("@comprehensive", () => { )(); // === TENANTS: PAGINATION === - // Rollout=100 (set above) ensures the default Enabled view contains every dev-DB tenant, which - // reliably exceeds the 25-row PageSize and renders the Next button. + // Re-pin rollout=100 right before the pagination check so cross-browser parallel `@comprehensive` + // runs that may have reset rollout to 0 during their cleanup can't empty the default Enabled view + // out from under us. With rollout=100, the dev-DB always exceeds the 25-row PageSize and renders + // the Next button. - await step("Click Next page in the default Enabled view & verify URL advances and the table re-renders")( - async () => { - const nextPageButton = page.getByRole("button", { name: "Next" }); - await expect(nextPageButton).toBeVisible(); - await nextPageButton.click(); + await step("Re-pin beta-features rollout to 100 and click Next page & verify URL advances")(async () => { + const rolloutResponse = await page.request.put( + `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, + { data: { rolloutPercentage: 100 }, headers: await getAntiforgeryHeaders(page) } + ); + expect(rolloutResponse.ok()).toBe(true); - await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsPageOffset=1`); - await expect(accountsTable).toBeVisible(); - } - )(); + // Reload so the tenants query refetches with the new rollout in effect. Without this, the prior + // query result (with whatever rollout cross-worker cleanup left in place) wins until natural + // invalidation, and the Next button stays hidden if that prior result was below the PageSize. + await page.reload(); + await expect(accountsTable.locator("tbody tr").first()).toBeVisible(); + + const nextPageButton = page.getByRole("button", { name: "Next" }); + await expect(nextPageButton).toBeVisible(); + await nextPageButton.dispatchEvent("click"); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsPageOffset=1`); + await expect(accountsTable).toBeVisible(); + })(); // === USERS: ROLE FILTER === @@ -353,7 +474,7 @@ test.describe("@comprehensive", () => { await step("Reset beta-features rollout to 0 via back-office API & verify success")(async () => { const response = await page.request.put( `${BACK_OFFICE_BASE_URL}/api/back-office/feature-flags/beta-features/rollout-percentage`, - { data: { rolloutPercentage: 0 } } + { data: { rolloutPercentage: 0 }, headers: await getAntiforgeryHeaders(page) } ); expect(response.ok()).toBe(true); From 840fb38a7bb9074aa2d7afe89d29c2a4e1e924f9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 21:18:58 +0200 Subject: [PATCH 062/155] Harden feature-flag schema and handlers with named query filters, FK on user_id, indexes, dead-row override fix, and Source enum --- ...645_AddFeatureFlagsForeignKeyAndIndexes.cs | 32 ++++++++ .../RemoveTenantFeatureFlagOverride.cs | 2 +- .../Commands/RemoveUserFeatureFlagOverride.cs | 2 +- .../Commands/SetTenantFeatureFlagInternal.cs | 16 ++-- .../Commands/SetTenantFeatureFlagOwner.cs | 2 +- .../Commands/SetUserFeatureFlag.cs | 8 +- .../Commands/SetUserFeatureFlagInternal.cs | 16 ++-- .../FeatureFlags/Domain/FeatureFlag.cs | 10 +-- .../Domain/FeatureFlagConfiguration.cs | 11 +++ .../Domain/FeatureFlagRepository.cs | 20 ++--- .../FeatureFlags/Domain/FeatureFlagTypes.cs | 13 +++- .../Queries/GetFeatureFlagTenants.cs | 18 ++--- .../Queries/GetFeatureFlagUsers.cs | 17 +++-- .../GetTenantConfigurableFeatureFlags.cs | 4 +- .../Queries/GetTenantFeatureFlags.cs | 16 ++-- .../GetUserConfigurableFeatureFlags.cs | 4 +- .../Queries/GetUserFeatureFlags.cs | 14 ++-- .../Shared/FeatureFlagEvaluator.cs | 9 +-- .../Shared/PlanBasedFeatureFlagEvaluator.cs | 4 +- .../Subscriptions/Domain/Subscription.cs | 4 + .../Tenants/Domain/TenantRepository.cs | 6 +- .../Features/Users/Domain/UserRepository.cs | 2 +- .../Features/Users/Shared/UserInfoFactory.cs | 2 +- .../FeatureFlagBackOfficeTests.cs | 42 +++++----- .../FeatureFlags/FeatureFlagEvaluatorTests.cs | 42 ++++------ .../GetTenantFeatureFlagsTests.cs | 6 +- .../FeatureFlags/GetUserFeatureFlagsTests.cs | 4 +- .../account/Tests/Users/PurgeUserTests.cs | 76 +++++++++++++++++++ 28 files changed, 253 insertions(+), 149 deletions(-) create mode 100644 application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs diff --git a/application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs b/application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs new file mode 100644 index 000000000..5ab60b76b --- /dev/null +++ b/application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260513185645_AddFeatureFlagsForeignKeyAndIndexes")] +public sealed class AddFeatureFlagsForeignKeyAndIndexes : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddForeignKey( + "fk_feature_flags_users_user_id", + "feature_flags", + "user_id", + "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + + // Indexes for the hot evaluator query paths. The pre-existing unique index leads on flag_key, which + // cannot serve the tenant-scoped / user-scoped lookups the evaluator runs. + migrationBuilder.CreateIndex("ix_feature_flags_tenant_id", "feature_flags", "tenant_id"); + migrationBuilder.CreateIndex("ix_feature_flags_user_id", "feature_flags", "user_id", filter: "user_id IS NOT NULL"); + migrationBuilder.CreateIndex( + "ix_feature_flags_tenant_id_source", + "feature_flags", + ["tenant_id", "source"], + filter: "source = 'Plan' AND user_id IS NULL" + ); + } +} diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs index 38f730eaa..46b07c060 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -34,7 +34,7 @@ public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepositor { public async Task Handle(RemoveTenantFeatureFlagOverrideCommand command, CancellationToken cancellationToken) { - var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, null, cancellationToken); + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); if (tenantOverride is null) return Result.NotFound($"No tenant override found for flag '{command.FlagKey}' and tenant '{command.TenantId}'."); featureFlagRepository.Remove(tenantOverride); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs index e57827fd5..88cc72af1 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs @@ -35,7 +35,7 @@ public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository { public async Task Handle(RemoveUserFeatureFlagOverrideCommand command, CancellationToken cancellationToken) { - var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, command.UserId.Value, cancellationToken); + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); if (userOverride is null) return Result.NotFound($"No user override found for flag '{command.FlagKey}' and user '{command.UserId}'."); featureFlagRepository.Remove(userOverride); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index 67957d2bd..ea16359bd 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -40,10 +40,10 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca if (command.Enabled) { - var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, null, cancellationToken); + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); if (tenantOverride is null) { - tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId.Value); + tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId); tenantOverride.Activate(now); await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); } @@ -57,19 +57,13 @@ public async Task Handle(SetTenantFeatureFlagInternalCommand command, Ca } else { - var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, null, cancellationToken); - if (tenantOverride is null) - { - tenantOverride = FeatureFlag.CreateTenantOverride(command.FlagKey, command.TenantId.Value); - await featureFlagRepository.AddAsync(tenantOverride, cancellationToken); - } - else + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); + if (tenantOverride is not null) { tenantOverride.Deactivate(now); featureFlagRepository.Update(tenantOverride); + events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); } - - events.CollectEvent(new FeatureFlagTenantOverrideRemoved(command.FlagKey, command.TenantId.ToString())); } return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs index dde084bcb..af79bd947 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagOwner.cs @@ -47,7 +47,7 @@ public async Task Handle(SetTenantFeatureFlagOwnerCommand command, Cance return Result.Forbidden($"Feature flag '{command.FlagKey}' is not configurable by tenant owners."); } - var tenantId = executionContext.TenantId!.Value; + var tenantId = executionContext.TenantId!; var now = timeProvider.GetUtcNow(); if (command.Enabled) diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs index 1b9a9d2a1..33d2f484a 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlag.cs @@ -41,8 +41,8 @@ public async Task Handle(SetUserFeatureFlagCommand command, Cancellation return Result.Forbidden($"Feature flag '{command.FlagKey}' is not configurable by users."); } - var tenantId = executionContext.TenantId!.Value; - var userId = executionContext.UserInfo.Id!.ToString(); + var tenantId = executionContext.TenantId!; + var userId = executionContext.UserInfo.Id!; var now = timeProvider.GetUtcNow(); if (command.Enabled) @@ -60,7 +60,7 @@ public async Task Handle(SetUserFeatureFlagCommand command, Cancellation featureFlagRepository.Update(userOverride); } - events.CollectEvent(new FeatureFlagUserOverrideSet(command.FlagKey, userId)); + events.CollectEvent(new FeatureFlagUserOverrideSet(command.FlagKey, userId.Value)); } else { @@ -69,7 +69,7 @@ public async Task Handle(SetUserFeatureFlagCommand command, Cancellation { userOverride.Deactivate(now); featureFlagRepository.Update(userOverride); - events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, userId)); + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, userId.Value)); } } diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs index 86013196f..4092897e6 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -41,10 +41,10 @@ public async Task Handle(SetUserFeatureFlagInternalCommand command, Canc if (command.Enabled) { - var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, command.UserId.Value, cancellationToken); + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); if (userOverride is null) { - userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId.Value, command.UserId.Value); + userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId, command.UserId); userOverride.Activate(now); await featureFlagRepository.AddAsync(userOverride, cancellationToken); } @@ -58,19 +58,13 @@ public async Task Handle(SetUserFeatureFlagInternalCommand command, Canc } else { - var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId.Value, command.UserId.Value, cancellationToken); - if (userOverride is null) - { - userOverride = FeatureFlag.CreateUserOverride(command.FlagKey, command.TenantId.Value, command.UserId.Value); - await featureFlagRepository.AddAsync(userOverride, cancellationToken); - } - else + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); + if (userOverride is not null) { userOverride.Deactivate(now); featureFlagRepository.Update(userOverride); + events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId.Value)); } - - events.CollectEvent(new FeatureFlagUserOverrideRemoved(command.FlagKey, command.UserId.Value)); } return Result.Success(); diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs index 796cb26cb..b3f53444e 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlag.cs @@ -13,7 +13,7 @@ private FeatureFlag() : base(FeatureFlagId.NewId()) FlagKey = string.Empty; } - private FeatureFlag(string flagKey, long? tenantId, string? userId, FeatureFlagSource source) + private FeatureFlag(string flagKey, TenantId? tenantId, UserId? userId, FeatureFlagSource source) : base(FeatureFlagId.NewId()) { FlagKey = flagKey; @@ -24,9 +24,9 @@ private FeatureFlag(string flagKey, long? tenantId, string? userId, FeatureFlagS public string FlagKey { get; private set; } - public long? TenantId { get; private set; } + public TenantId? TenantId { get; private set; } - public string? UserId { get; private set; } + public UserId? UserId { get; private set; } public DateTimeOffset? EnabledAt { get; private set; } @@ -54,12 +54,12 @@ public static FeatureFlag Create(string flagKey, FeatureFlagSource source = Feat return new FeatureFlag(flagKey, null, null, source); } - public static FeatureFlag CreateTenantOverride(string flagKey, long tenantId, FeatureFlagSource source = FeatureFlagSource.Manual) + public static FeatureFlag CreateTenantOverride(string flagKey, TenantId tenantId, FeatureFlagSource source = FeatureFlagSource.Manual) { return new FeatureFlag(flagKey, tenantId, null, source); } - public static FeatureFlag CreateUserOverride(string flagKey, long tenantId, string userId) + public static FeatureFlag CreateUserOverride(string flagKey, TenantId tenantId, UserId userId) { return new FeatureFlag(flagKey, tenantId, userId, FeatureFlagSource.Manual); } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs index 4d1d71c06..7249b9402 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagConfiguration.cs @@ -1,5 +1,7 @@ +using Account.Features.Users.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SharedKernel.Domain; using SharedKernel.EntityFramework; namespace Account.Features.FeatureFlags.Domain; @@ -9,5 +11,14 @@ public sealed class FeatureFlagConfiguration : IEntityTypeConfiguration builder) { builder.MapStronglyTypedUuid(f => f.Id); + builder.MapStronglyTypedNullableLongId(f => f.TenantId); + builder.MapStronglyTypedNullableId(f => f.UserId); + + // Cascade deletes user-scoped override rows when the user is hard-deleted (purge / bulk purge / + // recycle-bin empty). Without this, override rows would survive as orphans referencing a missing user. + builder.HasOne() + .WithMany() + .HasForeignKey(f => f.UserId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index c64bce92b..1c5d252bd 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -7,21 +7,21 @@ namespace Account.Features.FeatureFlags.Domain; public interface IFeatureFlagRepository : ICrudRepository { - Task GetAllRelevantRowsAsync(long tenantId, string userId, CancellationToken cancellationToken); + Task GetAllRelevantRowsAsync(TenantId tenantId, UserId userId, CancellationToken cancellationToken); - Task GetTenantScopedRowsAsync(long tenantId, CancellationToken cancellationToken); + Task GetTenantScopedRowsAsync(TenantId tenantId, CancellationToken cancellationToken); - Task GetUserScopedRowsAsync(long tenantId, string userId, CancellationToken cancellationToken); + Task GetUserScopedRowsAsync(TenantId tenantId, UserId userId, CancellationToken cancellationToken); Task GetAllBaseRowsAsync(CancellationToken cancellationToken); Task GetTenantOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); - Task GetByKeyAndScopeAsync(string flagKey, long? tenantId, string? userId, CancellationToken cancellationToken); + Task GetByKeyAndScopeAsync(string flagKey, TenantId? tenantId, UserId? userId, CancellationToken cancellationToken); Task GetUserOverridesForFlagAsync(string flagKey, CancellationToken cancellationToken); - Task GetPlanBasedOverridesForTenantAsync(long tenantId, CancellationToken cancellationToken); + Task GetPlanBasedOverridesForTenantAsync(TenantId tenantId, CancellationToken cancellationToken); /// /// Returns every feature_flag row across all tenants and users. Used by the reconciler to sweep for @@ -41,21 +41,21 @@ public interface IFeatureFlagRepository : ICrudRepository(accountDbContext), IFeatureFlagRepository { - public async Task GetAllRelevantRowsAsync(long tenantId, string userId, CancellationToken cancellationToken) + public async Task GetAllRelevantRowsAsync(TenantId tenantId, UserId userId, CancellationToken cancellationToken) { return await DbSet .Where(f => (f.TenantId == null || f.TenantId == tenantId) && (f.UserId == null || f.UserId == userId)) .ToArrayAsync(cancellationToken); } - public async Task GetTenantScopedRowsAsync(long tenantId, CancellationToken cancellationToken) + public async Task GetTenantScopedRowsAsync(TenantId tenantId, CancellationToken cancellationToken) { return await DbSet .Where(f => (f.TenantId == null || f.TenantId == tenantId) && f.UserId == null) .ToArrayAsync(cancellationToken); } - public async Task GetUserScopedRowsAsync(long tenantId, string userId, CancellationToken cancellationToken) + public async Task GetUserScopedRowsAsync(TenantId tenantId, UserId userId, CancellationToken cancellationToken) { return await DbSet .Where(f => (f.TenantId == null && f.UserId == null) || (f.TenantId == tenantId && f.UserId == userId)) @@ -76,7 +76,7 @@ public async Task GetTenantOverridesForFlagAsync(string flagKey, .ToArrayAsync(cancellationToken); } - public async Task GetByKeyAndScopeAsync(string flagKey, long? tenantId, string? userId, CancellationToken cancellationToken) + public async Task GetByKeyAndScopeAsync(string flagKey, TenantId? tenantId, UserId? userId, CancellationToken cancellationToken) { return await DbSet .FirstOrDefaultAsync(f => f.FlagKey == flagKey && f.TenantId == tenantId && f.UserId == userId, cancellationToken); @@ -89,7 +89,7 @@ public async Task GetUserOverridesForFlagAsync(string flagKey, Ca .ToArrayAsync(cancellationToken); } - public async Task GetPlanBasedOverridesForTenantAsync(long tenantId, CancellationToken cancellationToken) + public async Task GetPlanBasedOverridesForTenantAsync(TenantId tenantId, CancellationToken cancellationToken) { return await DbSet .Where(f => f.TenantId == tenantId && f.UserId == null && f.Source == FeatureFlagSource.Plan) diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs index a58fb7e0f..3497577cc 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagTypes.cs @@ -1,9 +1,18 @@ namespace Account.Features.FeatureFlags.Domain; +// JsonStringEnumMemberName preserves the snake-case wire vocabulary that the frontend already filters on, +// while letting C# callers use the enum directly. The `Default` member has no domain row counterpart — +// it is only used as a wire value when no override, plan grant, or A/B rollout applies. [JsonConverter(typeof(JsonStringEnumConverter))] public enum FeatureFlagSource { + [JsonStringEnumMemberName("manual_override")] Manual, - Plan, - AbRollout + + [JsonStringEnumMemberName("plan")] Plan, + + [JsonStringEnumMemberName("ab_rollout")] + AbRollout, + + [JsonStringEnumMemberName("default")] Default } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs index 63e794113..6e3173094 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagTenants.cs @@ -52,7 +52,7 @@ public sealed record FeatureFlagTenantInfo( TenantOwnerSummary? Owner, int RolloutBucket, bool IsEnabled, - string Source + FeatureFlagSource Source ); public sealed class GetFeatureFlagTenantsValidator : AbstractValidator @@ -112,7 +112,7 @@ public async Task> Handle(GetFeatureFlagTe } var tenantOverrides = await featureFlagRepository.GetTenantOverridesForFlagAsync(query.FlagKey, cancellationToken); - var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!.Value); + var overridesByTenantId = tenantOverrides.ToDictionary(f => f.TenantId!); var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(query.FlagKey, null, null, cancellationToken); @@ -142,7 +142,7 @@ public async Task> Handle(GetFeatureFlagTe if (query.HasOverride == true) { - filtered = filtered.Where(t => t.Source == "manual_override").ToArray(); + filtered = filtered.Where(t => t.Source == FeatureFlagSource.Manual).ToArray(); } var ordered = filtered.OrderBy(t => t.Name).ToArray(); @@ -159,27 +159,27 @@ public async Task> Handle(GetFeatureFlagTe return new GetFeatureFlagTenantsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); } - private static (bool IsEnabled, string Source) EvaluateOverride( + private static (bool IsEnabled, FeatureFlagSource Source) EvaluateOverride( FeatureFlagDefinition definition, FeatureFlag? baseRow, - Dictionary overridesByTenantId, + Dictionary overridesByTenantId, Tenant tenant ) { - if (overridesByTenantId.TryGetValue(tenant.Id.Value, out var tenantOverride)) + if (overridesByTenantId.TryGetValue(tenant.Id, out var tenantOverride)) { var isEnabled = tenantOverride.EnabledAt is not null && (tenantOverride.DisabledAt is null || tenantOverride.EnabledAt > tenantOverride.DisabledAt); // The override row's Source column distinguishes a manual admin toggle from a plan-driven row. - var source = tenantOverride.Source == FeatureFlagSource.Plan ? "plan" : "manual_override"; + var source = tenantOverride.Source == FeatureFlagSource.Plan ? FeatureFlagSource.Plan : FeatureFlagSource.Manual; return (isEnabled, source); } if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(tenant.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return (isInRange, "ab_rollout"); + return (isInRange, FeatureFlagSource.AbRollout); } - return (false, "default"); + return (false, FeatureFlagSource.Default); } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs index a772da334..39d484fb6 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetFeatureFlagUsers.cs @@ -51,7 +51,7 @@ public sealed record FeatureFlagUserInfo( SubscriptionPlan TenantPlan, int RolloutBucket, bool IsEnabled, - string Source + FeatureFlagSource Source ); public sealed class GetFeatureFlagUsersValidator : AbstractValidator @@ -93,6 +93,7 @@ public async Task> Handle(GetFeatureFlagUser var userOverrides = await featureFlagRepository.GetUserOverridesForFlagAsync(query.FlagKey, cancellationToken); var overridesByUserId = userOverrides.ToDictionary(f => f.UserId!); + var baseRow = await featureFlagRepository.GetByKeyAndScopeAsync(query.FlagKey, null, null, cancellationToken); var tenantIds = users.Select(u => u.TenantId).Distinct().ToArray(); @@ -125,7 +126,7 @@ public async Task> Handle(GetFeatureFlagUser if (query.HasOverride == true) { - filtered = filtered.Where(u => u.Source == "manual_override").ToArray(); + filtered = filtered.Where(u => u.Source == FeatureFlagSource.Manual).ToArray(); } var totalCount = filtered.Length; @@ -140,25 +141,25 @@ public async Task> Handle(GetFeatureFlagUser return new GetFeatureFlagUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); } - private static (bool IsEnabled, string Source) EvaluateOverride( + private static (bool IsEnabled, FeatureFlagSource Source) EvaluateOverride( FeatureFlagDefinition definition, FeatureFlag? baseRow, - Dictionary overridesByUserId, + Dictionary overridesByUserId, User user ) { - if (overridesByUserId.TryGetValue(user.Id.Value, out var userOverride)) + if (overridesByUserId.TryGetValue(user.Id, out var userOverride)) { var isEnabled = userOverride.EnabledAt is not null && (userOverride.DisabledAt is null || userOverride.EnabledAt > userOverride.DisabledAt); - return (isEnabled, "manual_override"); + return (isEnabled, FeatureFlagSource.Manual); } if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { var isInRange = RolloutBucketHasher.IsInRolloutBucketRange(user.RolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - return (isInRange, "ab_rollout"); + return (isInRange, FeatureFlagSource.AbRollout); } - return (false, "default"); + return (false, FeatureFlagSource.Default); } } diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs index 27122a471..507cd2041 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantConfigurableFeatureFlags.cs @@ -20,13 +20,13 @@ public sealed class GetTenantConfigurableFeatureFlagsHandler(IFeatureFlagReposit { public async Task> Handle(GetTenantConfigurableFeatureFlagsQuery query, CancellationToken cancellationToken) { - var tenantId = executionContext.TenantId!.Value; + var tenantId = executionContext.TenantId!; var configurableDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll() .Where(f => f is { Scope: FeatureFlagScope.Tenant, ConfigurableByTenant: true }) .ToArray(); - var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, string.Empty, cancellationToken); + var allRows = await featureFlagRepository.GetTenantScopedRowsAsync(tenantId, cancellationToken); var tenantOverrides = allRows.Where(r => r.TenantId == tenantId && r.UserId is null).ToDictionary(r => r.FlagKey); var flags = configurableDefinitions diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs index cb7ff70d2..e8d521ab0 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetTenantFeatureFlags.cs @@ -28,7 +28,7 @@ public sealed record TenantFeatureFlagInfo( int? BucketEnd, int? RolloutPercentage, bool IsEnabled, - string Source, + FeatureFlagSource Source, bool IsBaseRowActive, int RolloutBucket ); @@ -45,9 +45,9 @@ public async Task> Handle(GetTenantFeature .Where(f => f.Scope == FeatureFlagScope.Tenant) .ToArray(); - var allRows = await featureFlagRepository.GetTenantScopedRowsAsync(tenant.Id.Value, cancellationToken); + var allRows = await featureFlagRepository.GetTenantScopedRowsAsync(tenant.Id, cancellationToken); var baseRowsByKey = allRows.Where(r => r.TenantId is null).ToDictionary(r => r.FlagKey); - var tenantOverridesByKey = allRows.Where(r => r.TenantId == tenant.Id.Value).ToDictionary(r => r.FlagKey); + var tenantOverridesByKey = allRows.Where(r => r.TenantId == tenant.Id).ToDictionary(r => r.FlagKey); var flags = tenantScopedDefinitions .Select(definition => Evaluate(definition, baseRowsByKey, tenantOverridesByKey, tenant.RolloutBucket)) @@ -68,25 +68,25 @@ int tenantRolloutBucket tenantOverridesByKey.TryGetValue(definition.Key, out var tenantOverride); bool isEnabled; - string source; + FeatureFlagSource source; if (tenantOverride is not null) { isEnabled = tenantOverride.IsActive; // The row's Source column is authoritative — a manually-toggled plan-gated flag must still surface as - // "manual_override" so admins see they overrode the plan-driven default, rather than the plan granting it. - source = tenantOverride.Source == FeatureFlagSource.Plan ? "plan" : "manual_override"; + // Manual so admins see they overrode the plan-driven default, rather than the plan granting it. + source = tenantOverride.Source == FeatureFlagSource.Plan ? FeatureFlagSource.Plan : FeatureFlagSource.Manual; } else if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { isEnabled = isBaseRowActive && RolloutBucketHasher.IsInRolloutBucketRange(tenantRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - source = "ab_rollout"; + source = FeatureFlagSource.AbRollout; } else { isEnabled = false; - source = "default"; + source = FeatureFlagSource.Default; } return new TenantFeatureFlagInfo( diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs index c3726a091..bcb45db19 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserConfigurableFeatureFlags.cs @@ -20,14 +20,14 @@ public sealed class GetUserConfigurableFeatureFlagsHandler(IFeatureFlagRepositor { public async Task> Handle(GetUserConfigurableFeatureFlagsQuery query, CancellationToken cancellationToken) { - var tenantId = executionContext.TenantId!.Value; + var tenantId = executionContext.TenantId!; var userId = executionContext.UserInfo.Id!; var configurableDefinitions = SharedKernel.FeatureFlags.FeatureFlags.GetAll() .Where(f => f is { Scope: FeatureFlagScope.User, ConfigurableByUser: true }) .ToArray(); - var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, userId, cancellationToken); + var allRows = await featureFlagRepository.GetUserScopedRowsAsync(tenantId, userId, cancellationToken); var userOverrides = allRows.Where(r => r.TenantId == tenantId && r.UserId == userId).ToDictionary(r => r.FlagKey); var flags = configurableDefinitions diff --git a/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs b/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs index 237fa9eb9..bb1b8fc45 100644 --- a/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs +++ b/application/account/Core/Features/FeatureFlags/Queries/GetUserFeatureFlags.cs @@ -27,7 +27,7 @@ public sealed record UserFeatureFlagInfo( int? BucketEnd, int? RolloutPercentage, bool IsEnabled, - string Source, + FeatureFlagSource Source, bool IsBaseRowActive, int RolloutBucket, TenantId TenantId @@ -45,9 +45,9 @@ public async Task> Handle(GetUserFeatureFlag .Where(f => f.Scope == FeatureFlagScope.User) .ToArray(); - var allRows = await featureFlagRepository.GetUserScopedRowsAsync(user.TenantId.Value, user.Id.Value, cancellationToken); + var allRows = await featureFlagRepository.GetUserScopedRowsAsync(user.TenantId, user.Id, cancellationToken); var baseRowsByKey = allRows.Where(r => r.TenantId is null && r.UserId is null).ToDictionary(r => r.FlagKey); - var userOverridesByKey = allRows.Where(r => r.UserId == user.Id.Value).ToDictionary(r => r.FlagKey); + var userOverridesByKey = allRows.Where(r => r.UserId == user.Id).ToDictionary(r => r.FlagKey); var flags = userScopedDefinitions .Select(definition => Evaluate(definition, baseRowsByKey, userOverridesByKey, user.RolloutBucket, user.TenantId)) @@ -69,23 +69,23 @@ TenantId tenantId userOverridesByKey.TryGetValue(definition.Key, out var userOverride); bool isEnabled; - string source; + FeatureFlagSource source; if (userOverride is not null) { isEnabled = userOverride.IsActive; - source = "manual_override"; + source = FeatureFlagSource.Manual; } else if (definition.IsAbTestEligible && baseRow?.BucketStart is not null && baseRow.BucketEnd is not null) { isEnabled = isBaseRowActive && RolloutBucketHasher.IsInRolloutBucketRange(userRolloutBucket, baseRow.BucketStart.Value, baseRow.BucketEnd.Value); - source = "ab_rollout"; + source = FeatureFlagSource.AbRollout; } else { isEnabled = false; - source = "default"; + source = FeatureFlagSource.Default; } return new UserFeatureFlagInfo( diff --git a/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs b/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs index 0d9bf2419..61a9868f5 100644 --- a/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/FeatureFlagEvaluator.cs @@ -1,11 +1,12 @@ using Account.Features.FeatureFlags.Domain; +using SharedKernel.Domain; using SharedKernel.FeatureFlags; namespace Account.Features.FeatureFlags.Shared; public sealed class FeatureFlagEvaluator(IFeatureFlagRepository featureFlagRepository) { - public async Task> EvaluateAsync(long tenantId, string userId, int tenantRolloutBucket, int? userRolloutBucket, CancellationToken cancellationToken) + public async Task> EvaluateAsync(TenantId tenantId, UserId userId, int tenantRolloutBucket, int? userRolloutBucket, CancellationToken cancellationToken) { var allRows = await featureFlagRepository.GetAllRelevantRowsAsync(tenantId, userId, cancellationToken); var enabledFeatureFlags = new List(); @@ -44,7 +45,7 @@ public async Task> EvaluateAsync(long tenantId, string use return enabledFeatureFlags; } - private static bool EvaluateTenantScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, long tenantId, int tenantRolloutBucket) + private static bool EvaluateTenantScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, TenantId tenantId, int tenantRolloutBucket) { var tenantOverride = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId == tenantId && f.UserId is null); if (tenantOverride is not null) @@ -60,10 +61,8 @@ private static bool EvaluateTenantScope(FeatureFlagDefinition definition, Featur return false; } - private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, long tenantId, string userId, int? userRolloutBucket) + private static bool EvaluateUserScope(FeatureFlagDefinition definition, FeatureFlag baseRow, FeatureFlag[] allRows, TenantId tenantId, UserId userId, int? userRolloutBucket) { - if (string.IsNullOrEmpty(userId)) return false; - var userOverride = allRows.FirstOrDefault(f => f.FlagKey == definition.Key && f.TenantId == tenantId && f.UserId == userId); if (userOverride is not null) { diff --git a/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs index fadb6008c..29f91c8c2 100644 --- a/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs +++ b/application/account/Core/Features/FeatureFlags/Shared/PlanBasedFeatureFlagEvaluator.cs @@ -14,7 +14,7 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio if (planFeatureFlagDefinitions.Length == 0) return; - var existingOverrides = await featureFlagRepository.GetPlanBasedOverridesForTenantAsync(tenantId.Value, cancellationToken); + var existingOverrides = await featureFlagRepository.GetPlanBasedOverridesForTenantAsync(tenantId, cancellationToken); var overridesByKey = existingOverrides.ToDictionary(f => f.FlagKey); var now = timeProvider.GetUtcNow(); @@ -27,7 +27,7 @@ public async Task EvaluatePlanFlagsForTenantAsync(TenantId tenantId, Subscriptio { if (existingOverride is null) { - var featureFlag = FeatureFlag.CreateTenantOverride(definition.Key, tenantId.Value, FeatureFlagSource.Plan); + var featureFlag = FeatureFlag.CreateTenantOverride(definition.Key, tenantId, FeatureFlagSource.Plan); featureFlag.Activate(now); await featureFlagRepository.AddAsync(featureFlag, cancellationToken); } diff --git a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index 7c23119b3..c351ac00c 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -189,6 +189,10 @@ public void SetPaymentMethod(PaymentMethod? paymentMethod) PaymentMethod = paymentMethod; } + // ENTITLEMENT POLICY: Failed payments do NOT immediately revoke plan-tier entitlements. + // Stripe Smart Retries handle recovery during the dunning window (typically ~3 weeks). + // If all retries fail, Stripe cancels the subscription server-side and subscriptionSuspended + // flips Plan to Basis. During the retry window the customer keeps premium features by design. public void SetPaymentFailed(DateTimeOffset failedAt) { FirstPaymentFailedAt = failedAt; diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 8a51dd2ae..3af8c26e9 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -83,7 +83,9 @@ public async Task GetByIdsAsync(TenantId[] ids, CancellationToken canc /// public async Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters().Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); + if (ids.Length == 0) return []; + + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); } /// @@ -170,6 +172,6 @@ public async Task GetMostRecentSignupsUnfilteredAsync(int limit, Cance /// public async Task GetCountUnfilteredAsync(CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters().CountAsync(cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(cancellationToken); } } diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index e19a7c347..92ba9251c 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -564,7 +564,7 @@ public async Task GetAllUnfilteredAsync(CancellationToken cancellationTo /// public async Task GetCountUnfilteredAsync(CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters().CountAsync(cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(cancellationToken); } /// diff --git a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs index 16b4e1d27..4e0473210 100644 --- a/application/account/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account/Core/Features/Users/Shared/UserInfoFactory.cs @@ -27,7 +27,7 @@ public async Task> CreateUserInfoAsync(User user, SessionId? se await planBasedFeatureFlagEvaluator.EvaluatePlanFlagsForTenantAsync(tenant.Id, subscription!.Plan, cancellationToken); - var enabledFlags = await featureFlagEvaluator.EvaluateAsync(tenant.Id.Value, user.Id.Value, tenant.RolloutBucket, user.RolloutBucket, cancellationToken); + var enabledFlags = await featureFlagEvaluator.EvaluateAsync(tenant.Id, user.Id, tenant.RolloutBucket, user.RolloutBucket, cancellationToken); return new UserInfo { diff --git a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs index 19b536719..5ff04b35c 100644 --- a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs +++ b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs @@ -207,9 +207,10 @@ public async Task SetTenantOverride_WhenEnabledAsRegularBackOffice_ShouldCreateO } [Fact] - public async Task SetTenantOverride_WhenDisabledWithNoExistingOverride_ShouldCreateDisabledOverrideRow() + public async Task SetTenantOverride_WhenDisabledWithNoExistingOverride_ShouldNotInsertRowOrEmitTelemetry() { - // Arrange - tenant has no override row (enabled via A/B rollout or default). + // Arrange - tenant has no override row. Disabling a non-existent override is a no-op: + // no dead-row insert, no telemetry. Mirrors the Owner-variant handler's early-return. var flagKey = "beta-features"; var tenantId = DatabaseSeeder.Tenant1.Id; using var client = CreateRegularBackOfficeClient(); @@ -225,14 +226,9 @@ public async Task SetTenantOverride_WhenDisabledWithNoExistingOverride_ShouldCre "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", [new { flagKey, tenantId = tenantId.Value }] ); - rowCount.Should().Be(1); - var disabledAt = Connection.ExecuteScalar( - "SELECT disabled_at FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", - [new { flagKey, tenantId = tenantId.Value }] - ); - disabledAt.Should().BeNull("a newly created disabled override should not have disabled_at set when never activated"); + rowCount.Should().Be(0); - TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagTenantOverrideRemoved"); + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); } [Fact] @@ -495,7 +491,7 @@ public async Task GetFeatureFlagTenants_WhenTenantScopedFlag_ShouldReturnAllTena result.Tenants.Should().NotBeEmpty(); result.Tenants.Should().AllSatisfy(t => { - t.Source.Should().Be("default"); + t.Source.Should().Be(FeatureFlagSource.Default); t.IsEnabled.Should().BeFalse(); } ); @@ -519,7 +515,7 @@ public async Task GetFeatureFlagTenants_WhenTenantHasOverride_ShouldReturnManual result.Should().NotBeNull(); var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); tenantResult.IsEnabled.Should().BeTrue(); - tenantResult.Source.Should().Be("manual_override"); + tenantResult.Source.Should().Be(FeatureFlagSource.Manual); } [Fact] @@ -546,7 +542,7 @@ public async Task GetFeatureFlagTenants_WhenFlagHasRollout_ShouldReturnAbRollout result.Should().NotBeNull(); result.Tenants.Should().AllSatisfy(t => { - t.Source.Should().Be("ab_rollout"); + t.Source.Should().Be(FeatureFlagSource.AbRollout); t.IsEnabled.Should().BeTrue(); } ); @@ -578,7 +574,7 @@ public async Task GetFeatureFlagTenants_WhenTenantDisabledViaOverrideWhileAbRoll result.Should().NotBeNull(); var tenantResult = result.Tenants.Single(t => t.Id.Value == tenantId); tenantResult.IsEnabled.Should().BeFalse(); - tenantResult.Source.Should().Be("manual_override"); + tenantResult.Source.Should().Be(FeatureFlagSource.Manual); } [Fact] @@ -769,7 +765,7 @@ public async Task GetFeatureFlagTenants_WhenHasOverrideOmitted_ShouldNotFilterBy response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Tenants.Should().Contain(t => t.Source == "manual_override"); + result.Tenants.Should().Contain(t => t.Source == FeatureFlagSource.Manual); } [Fact] @@ -788,7 +784,7 @@ public async Task GetFeatureFlagTenants_WhenHasOverrideTrue_ShouldReturnOnlyTena response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().OnlyContain(t => t.Source == FeatureFlagSource.Manual); result.Tenants.Should().Contain(t => t.Id.Value == tenantId); } @@ -808,7 +804,7 @@ public async Task GetFeatureFlagTenants_WhenHasOverrideTrueAndStateDisabled_Shou response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override" && !t.IsEnabled); + result.Tenants.Should().OnlyContain(t => t.Source == FeatureFlagSource.Manual && !t.IsEnabled); result.Tenants.Should().Contain(t => t.Id.Value == tenantId); } @@ -855,7 +851,7 @@ public async Task GetFeatureFlagTenants_WhenHasOverrideTrueOnAbRolloutFlag_Shoul response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Tenants.Should().OnlyContain(t => t.Source == "manual_override"); + result.Tenants.Should().OnlyContain(t => t.Source == FeatureFlagSource.Manual); result.Tenants.Should().Contain(t => t.Id.Value == tenantId); } @@ -897,7 +893,7 @@ public async Task GetFeatureFlagUsers_WhenSearchMatchesEmail_ShouldReturnMatchin var result = await response.DeserializeResponse(); result.Should().NotBeNull(); result.Users.Should().NotBeEmpty(); - result.Users.Should().OnlyContain(u => u.Source == "default"); + result.Users.Should().OnlyContain(u => u.Source == FeatureFlagSource.Default); } [Fact] @@ -919,7 +915,7 @@ public async Task GetFeatureFlagUsers_WhenUserHasOverride_ShouldReturnUserWithMa result.Users.Should().NotBeEmpty(); var userResult = result.Users.Single(u => u.Id.Value == userId); userResult.IsEnabled.Should().BeTrue(); - userResult.Source.Should().Be("manual_override"); + userResult.Source.Should().Be(FeatureFlagSource.Manual); userResult.Email.Should().NotBe("Unknown"); userResult.TenantName.Should().NotBe("Unknown"); } @@ -1052,8 +1048,8 @@ public async Task GetFeatureFlagUsers_WhenHasOverrideOmitted_ShouldNotFilterByOv response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Users.Should().Contain(u => u.Source == "manual_override"); - result.Users.Should().Contain(u => u.Source == "default"); + result.Users.Should().Contain(u => u.Source == FeatureFlagSource.Manual); + result.Users.Should().Contain(u => u.Source == FeatureFlagSource.Default); } [Fact] @@ -1072,7 +1068,7 @@ public async Task GetFeatureFlagUsers_WhenHasOverrideTrue_ShouldReturnOnlyUsersW response.ShouldBeSuccessfulGetRequest(); var result = await response.DeserializeResponse(); result.Should().NotBeNull(); - result.Users.Should().OnlyContain(u => u.Source == "manual_override"); + result.Users.Should().OnlyContain(u => u.Source == FeatureFlagSource.Manual); result.Users.Should().Contain(u => u.Id.Value == userId); } @@ -1094,7 +1090,7 @@ public async Task GetFeatureFlagUsers_WhenHasOverrideTrueAndRoleFiltered_ShouldR noMatchResponse.ShouldBeSuccessfulGetRequest(); var matchResult = await matchResponse.DeserializeResponse(); var noMatchResult = await noMatchResponse.DeserializeResponse(); - matchResult!.Users.Should().OnlyContain(u => u.Source == "manual_override" && u.Role == UserRole.Owner); + matchResult!.Users.Should().OnlyContain(u => u.Source == FeatureFlagSource.Manual && u.Role == UserRole.Owner); matchResult.Users.Should().Contain(u => u.Id.Value == ownerId); noMatchResult!.Users.Should().BeEmpty(); } diff --git a/application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs b/application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs index 6708d0b8a..5a09a96f8 100644 --- a/application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs +++ b/application/account/Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs @@ -25,7 +25,7 @@ public FeatureFlagEvaluatorTests() public async Task Evaluate_WhenNoBaseRow_ShouldReturnEmpty() { // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().BeEmpty(); @@ -40,7 +40,7 @@ public async Task Evaluate_WhenBaseRowActiveWithTenantOverride_ShouldReturnEnabl InsertFeatureFlag("sso", DatabaseSeeder.Tenant1.Id.Value, null, now, null, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().Contain("sso"); @@ -53,7 +53,7 @@ public async Task Evaluate_WhenBaseRowInactive_ShouldReturnEmpty() InsertFeatureFlag("sso", null, null, null, null, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().NotContain("sso"); @@ -68,7 +68,7 @@ public async Task Evaluate_WhenReactivated_EnabledAtAfterDisabledAt_ShouldReturn InsertFeatureFlag("sso", DatabaseSeeder.Tenant1.Id.Value, null, now, null, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().Contain("sso"); @@ -82,7 +82,7 @@ public async Task Evaluate_WhenDisabledAtAfterEnabledAt_ShouldReturnEmpty() InsertFeatureFlag("sso", null, null, now.AddMinutes(-10), now, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().NotContain("sso"); @@ -96,7 +96,7 @@ public async Task Evaluate_WhenAbTestEligibleAndBucketInRange_ShouldReturnEnable InsertFeatureFlag("beta-features", null, null, now, null, 40, 60); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, null, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, null, CancellationToken.None); // Assert result.Should().Contain("beta-features"); @@ -110,7 +110,7 @@ public async Task Evaluate_WhenAbTestEligibleAndBucketOutOfRange_ShouldReturnEmp InsertFeatureFlag("beta-features", null, null, now, null, 40, 60); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 70, null, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 70, null, CancellationToken.None); // Assert result.Should().NotContain("beta-features"); @@ -124,9 +124,9 @@ public async Task Evaluate_WhenBucketWrapAround_ShouldIncludeWrappedRange() InsertFeatureFlag("beta-features", null, null, now, null, 90, 10); // Act - var resultInUpperRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 95, null, CancellationToken.None); - var resultInLowerRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 5, null, CancellationToken.None); - var resultOutOfRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, null, CancellationToken.None); + var resultInUpperRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 95, null, CancellationToken.None); + var resultInLowerRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 5, null, CancellationToken.None); + var resultOutOfRange = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, null, CancellationToken.None); // Assert resultInUpperRange.Should().Contain("beta-features"); @@ -142,7 +142,7 @@ public async Task Evaluate_WhenNullBucketStartAndEnd_ShouldNotEnableViaRollout() InsertFeatureFlag("beta-features", null, null, now, null, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, null, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, null, CancellationToken.None); // Assert result.Should().NotContain("beta-features"); @@ -157,26 +157,12 @@ public async Task Evaluate_WhenUserOverrideActive_ShouldReturnEnabled() InsertFeatureFlag("compact-view", DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, now, null, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().Contain("compact-view"); } - [Fact] - public async Task Evaluate_WhenUserIdEmpty_ShouldSkipUserScopedFlags() - { - // Arrange - var now = TimeProvider.System.GetUtcNow(); - InsertFeatureFlag("compact-view", null, null, now, null, null, null); - - // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, "", 50, 50, CancellationToken.None); - - // Assert - result.Should().NotContain("compact-view"); - } - [Fact] public async Task Evaluate_WhenTenantOverrideDisabled_ShouldNotReturnFlag() { @@ -186,7 +172,7 @@ public async Task Evaluate_WhenTenantOverrideDisabled_ShouldNotReturnFlag() InsertFeatureFlag("sso", DatabaseSeeder.Tenant1.Id.Value, null, now.AddMinutes(-10), now, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().NotContain("sso"); @@ -202,7 +188,7 @@ public async Task Evaluate_WhenTenantOverrideExistsForDifferentTenant_ShouldNotR InsertFeatureFlag("sso", otherTenantId, null, now, null, null, null); // Act - var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id.Value, DatabaseSeeder.Tenant1Owner.Id.Value, 50, 50, CancellationToken.None); + var result = await _evaluationService.EvaluateAsync(DatabaseSeeder.Tenant1.Id, DatabaseSeeder.Tenant1Owner.Id, 50, 50, CancellationToken.None); // Assert result.Should().NotContain("sso"); diff --git a/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs b/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs index 853ab3ea4..113762af5 100644 --- a/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs +++ b/application/account/Tests/FeatureFlags/GetTenantFeatureFlagsTests.cs @@ -53,7 +53,7 @@ public async Task GetTenantFeatureFlags_WhenTenantHasManualOverrideOnPlanGatedFl payload.Should().NotBeNull(); var sso = payload.Flags.Single(f => f.FlagKey == "sso"); sso.IsEnabled.Should().BeTrue(); - sso.Source.Should().Be("manual_override"); + sso.Source.Should().Be(FeatureFlagSource.Manual); } [Fact] @@ -75,7 +75,7 @@ public async Task GetTenantFeatureFlags_WhenTenantHasPlanGrantedRow_ShouldReturn payload.Should().NotBeNull(); var sso = payload.Flags.Single(f => f.FlagKey == "sso"); sso.IsEnabled.Should().BeTrue(); - sso.Source.Should().Be("plan"); + sso.Source.Should().Be(FeatureFlagSource.Plan); } [Fact] @@ -104,7 +104,7 @@ public async Task GetTenantFeatureFlags_WhenTenantInAbRolloutRange_ShouldReturnA payload.Should().NotBeNull(); var betaFeatures = payload.Flags.Single(f => f.FlagKey == "beta-features"); betaFeatures.IsEnabled.Should().BeTrue(); - betaFeatures.Source.Should().Be("ab_rollout"); + betaFeatures.Source.Should().Be(FeatureFlagSource.AbRollout); } [Fact] diff --git a/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs b/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs index ba081e84b..879dfa43e 100644 --- a/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs +++ b/application/account/Tests/FeatureFlags/GetUserFeatureFlagsTests.cs @@ -67,7 +67,7 @@ public async Task GetUserFeatureFlags_WhenUserHasManualOverride_ShouldReturnManu payload.Should().NotBeNull(); var compactView = payload.Flags.Single(f => f.FlagKey == "compact-view"); compactView.IsEnabled.Should().BeTrue(); - compactView.Source.Should().Be("manual_override"); + compactView.Source.Should().Be(FeatureFlagSource.Manual); } [Fact] @@ -96,7 +96,7 @@ public async Task GetUserFeatureFlags_WhenUserInAbRolloutRange_ShouldReturnAbRol payload.Should().NotBeNull(); var experimentalUi = payload.Flags.Single(f => f.FlagKey == "experimental-ui"); experimentalUi.IsEnabled.Should().BeTrue(); - experimentalUi.Source.Should().Be("ab_rollout"); + experimentalUi.Source.Should().Be(FeatureFlagSource.AbRollout); } [Fact] diff --git a/application/account/Tests/Users/PurgeUserTests.cs b/application/account/Tests/Users/PurgeUserTests.cs index 3761f7c23..ffaab3bb5 100644 --- a/application/account/Tests/Users/PurgeUserTests.cs +++ b/application/account/Tests/Users/PurgeUserTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json; using Account.Database; +using Account.Features.FeatureFlags.Domain; using Account.Features.Users.Domain; using FluentAssertions; using SharedKernel.Domain; @@ -104,4 +105,79 @@ public async Task PurgeUser_WhenUserNotDeleted_ShouldReturnNotFound() // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"Deleted user with id '{activeUserId}' not found."); } + + [Fact] + public async Task PurgeUser_WhenUserHasFeatureFlagOverrides_ShouldCascadeDeleteOverrideRows() + { + // Arrange - soft-deleted user with two user-scoped feature-flag override rows. Purge must cascade + // them to keep the feature_flags table free of orphans pointing at a removed user. + var deletedUserId = UserId.NewId(); + Connection.Insert("users", [ + ("tenant_id", DatabaseSeeder.Tenant1.Id.ToString()), + ("id", deletedUserId.ToString()), + ("created_at", TimeProvider.GetUtcNow().AddDays(-10)), + ("modified_at", TimeProvider.GetUtcNow().AddDays(-1)), + ("deleted_at", TimeProvider.GetUtcNow().AddDays(-1)), + ("email", Faker.Internet.UniqueEmail()), + ("first_name", Faker.Person.FirstName), + ("last_name", Faker.Person.LastName), + ("title", "Former Employee"), + ("role", nameof(UserRole.Member)), + ("email_confirmed", true), + ("avatar", JsonSerializer.Serialize(new Avatar())), + ("locale", "en-US"), + ("external_identities", "[]"), + ("rollout_bucket", 42) + ] + ); + + var now = TimeProvider.GetUtcNow(); + var flagRowId1 = FeatureFlagId.NewId().ToString(); + var flagRowId2 = FeatureFlagId.NewId().ToString(); + Connection.Insert("feature_flags", [ + ("id", flagRowId1), + ("created_at", now), + ("modified_at", null), + ("flag_key", "compact-view"), + ("tenant_id", DatabaseSeeder.Tenant1.Id.Value), + ("user_id", deletedUserId.ToString()), + ("enabled_at", now), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + Connection.Insert("feature_flags", [ + ("id", flagRowId2), + ("created_at", now), + ("modified_at", null), + ("flag_key", "experimental-ui"), + ("tenant_id", DatabaseSeeder.Tenant1.Id.Value), + ("user_id", deletedUserId.ToString()), + ("enabled_at", now), + ("disabled_at", null), + ("bucket_start", null), + ("bucket_end", null), + ("configurable_by_tenant", false), + ("configurable_by_user", false), + ("source", "Manual") + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/api/account/users/{deletedUserId}/purge"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + Connection.RowExists("users", deletedUserId.ToString()).Should().BeFalse(); + + var orphanCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE user_id = @userId", + [new { userId = deletedUserId.ToString() }] + ); + orphanCount.Should().Be(0, "feature_flags rows for the purged user must cascade-delete"); + } } From 5238281a9792a0214dfc74cdd49e601a6b0ed22c Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 22:11:51 +0200 Subject: [PATCH 063/155] Consolidate user feature flag header emission into AuthenticationCookieMiddleware --- .../AuthenticationCookiePathTests.cs | 232 +++++++++++++++--- .../UserFeatureFlagsResponseTransformTests.cs | 213 ---------------- .../AuthenticationCookieMiddleware.cs | 125 +++++++++- application/AppGateway/Program.cs | 4 +- .../UserFeatureFlagsResponseTransform.cs | 195 --------------- 5 files changed, 313 insertions(+), 456 deletions(-) delete mode 100644 application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs delete mode 100644 application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs diff --git a/application/AppGateway.Tests/AuthenticationCookiePathTests.cs b/application/AppGateway.Tests/AuthenticationCookiePathTests.cs index 2ecec297c..e9fb165ac 100644 --- a/application/AppGateway.Tests/AuthenticationCookiePathTests.cs +++ b/application/AppGateway.Tests/AuthenticationCookiePathTests.cs @@ -1,42 +1,45 @@ +using System.Net; +using System.Security.Claims; using AppGateway.Middleware; -using AppGateway.Transformations; using FluentAssertions; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using SharedKernel.Authentication; using SharedKernel.Authentication.TokenSigning; using Xunit; -using Yarp.ReverseProxy.Transforms; namespace AppGateway.Tests; public sealed class AuthenticationCookiePathTests(AppGatewayApplicationFactory factory) : IClassFixture { [Fact] - public async Task ApplyAsync_WhenUpstreamResponseCarriesTokenHeaders_ShouldIssueHostCookiesWithPathSlash() + public async Task InvokeAsync_WhenUpstreamResponseCarriesTokenHeaders_ShouldIssueHostCookiesWithPathSlash() { - // Login / signup / switch-tenant flows return the new tokens in response headers; the YARP - // response transform converts them to __Host- cookies with Path=/ before egress. + // Arrange await using var scope = factory.Services.CreateAsyncScope(); - var transform = scope.ServiceProvider.GetRequiredService(); + var middleware = scope.ServiceProvider.GetRequiredService(); var signingClient = scope.ServiceProvider.GetRequiredService(); + var refreshToken = CreateSignedToken(signingClient, 60, []); + var accessToken = CreateSignedToken(signingClient, 5, []); + var context = CreateHttpContext("/api/account/authentication/switch-tenant"); - var refreshToken = CreateSignedToken(signingClient, 60); - var accessToken = CreateSignedToken(signingClient, 5); - - var context = new DefaultHttpContext - { - Request = { Path = "/api/account/authentication/switch-tenant" }, - Response = { Body = new MemoryStream() } - }; - context.Response.Headers[AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey] = refreshToken; - context.Response.Headers[AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey] = accessToken; - var transformContext = new ResponseTransformContext { HttpContext = context }; - - await transform.ApplyAsync(transformContext); + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey] = refreshToken; + downstream.Response.Headers[AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey] = accessToken; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + // Assert var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.RefreshTokenCookieName)); setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.AccessTokenCookieName)); @@ -47,28 +50,20 @@ public async Task ApplyAsync_WhenUpstreamResponseCarriesTokenHeaders_ShouldIssue } [Fact] - public async Task ExpiredRefreshToken_WhenMiddlewareDeletesCookies_ShouldIssueDeletionWithPathSlash() + public async Task InvokeAsync_WhenRefreshTokenCookieHasExpired_ShouldDeleteCookiesWithPathSlash() { // Arrange await using var scope = factory.Services.CreateAsyncScope(); var middleware = scope.ServiceProvider.GetRequiredService(); var signingClient = scope.ServiceProvider.GetRequiredService(); - - var expiredRefreshToken = CreateSignedToken(signingClient, -10); - var expiredAccessToken = CreateSignedToken(signingClient, -10); - - var context = new DefaultHttpContext - { - Request = - { - Path = "/some-spa-path", - Headers = { Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={expiredRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={expiredAccessToken}" } - }, - Response = { Body = new MemoryStream() } - }; + var expiredRefreshToken = CreateSignedToken(signingClient, -10, []); + var expiredAccessToken = CreateSignedToken(signingClient, -10, []); + var context = CreateHttpContext("/some-spa-path"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={expiredRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={expiredAccessToken}"; // Act await middleware.InvokeAsync(context, _ => Task.CompletedTask); + await TriggerOnStartingAsync(context); // Assert var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); @@ -79,7 +74,101 @@ public async Task ExpiredRefreshToken_WhenMiddlewareDeletesCookies_ShouldIssueDe } } - private static string CreateSignedToken(ITokenSigningClient signingClient, int validForMinutes) + [Fact] + public async Task InvokeAsync_WhenAuthenticatedRequestHasFeatureFlagsClaim_ShouldEmitUserFeatureFlagsHeader() + { + // Arrange + await using var scope = factory.Services.CreateAsyncScope(); + var middleware = scope.ServiceProvider.GetRequiredService(); + var signingClient = scope.ServiceProvider.GetRequiredService(); + var refreshToken = CreateSignedToken(signingClient, 60, []); + var accessToken = CreateSignedToken(signingClient, 5, [new Claim("feature_flags", "custom-branding,compact-view")]); + var context = CreateHttpContext("/api/account/me"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={refreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={accessToken}"; + + // Act + await middleware.InvokeAsync(context, _ => Task.CompletedTask); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("custom-branding,compact-view"); + } + + [Fact] + public async Task InvokeAsync_WhenUpstreamSetsRefreshAuthenticationTokensHeader_ShouldEmitHeaderReflectingPostRefreshJwt() + { + // Arrange + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var middleware = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + var inboundRefreshToken = CreateSignedToken(signingClient, 60, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim("feature_flags", "stale-flag")]); + var postRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim("feature_flags", "custom-branding")]); + var postRefreshRefreshToken = CreateSignedToken(signingClient, 60, []); + RefreshStubAppGatewayApplicationFactory.SetStubResponse(postRefreshRefreshToken, postRefreshAccessToken); + var context = CreateHttpContext("/api/account/feature-flags/custom-branding/tenant-override"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={inboundRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={preRefreshAccessToken}"; + + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("custom-branding"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); + setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.RefreshTokenCookieName)); + setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.AccessTokenCookieName)); + } + + [Fact] + public async Task InvokeAsync_WhenRefreshEndpointSignalsSessionRevoked_ShouldOverwriteResponseWith401AndClearCookies() + { + // Arrange + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var middleware = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + var inboundRefreshToken = CreateSignedToken(signingClient, 60, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim("feature_flags", "stale-flag")]); + RefreshStubAppGatewayApplicationFactory.SetStubRevoked("ReplayAttackDetected"); + var context = CreateHttpContext("/api/account/feature-flags/custom-branding/tenant-override"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={inboundRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={preRefreshAccessToken}"; + + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey].ToString().Should().Be("ReplayAttackDetected"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + + private static DefaultHttpContext CreateHttpContext(string path) + { + var context = new DefaultHttpContext { Request = { Path = path }, Response = { Body = new MemoryStream() } }; + context.Features.Set(new CapturingResponseFeature()); + return context; + } + + private static Task TriggerOnStartingAsync(HttpContext context) + { + var feature = (CapturingResponseFeature)context.Features.GetRequiredFeature(); + return feature.TriggerOnStartingAsync(); + } + + private static string CreateSignedToken(ITokenSigningClient signingClient, int validForMinutes, Claim[] claims) { var now = DateTimeOffset.UtcNow; var notBefore = validForMinutes >= 0 ? now.UtcDateTime : now.AddMinutes(validForMinutes - 1).UtcDateTime; @@ -90,8 +179,81 @@ private static string CreateSignedToken(ITokenSigningClient signingClient, int v Expires = now.AddMinutes(validForMinutes).UtcDateTime, Issuer = signingClient.Issuer, Audience = signingClient.Audience, - SigningCredentials = signingClient.GetSigningCredentials() + SigningCredentials = signingClient.GetSigningCredentials(), + Subject = claims.Length == 0 ? null : new ClaimsIdentity(claims) }; return new JsonWebTokenHandler().CreateToken(descriptor); } + + /// + /// DefaultHttpContext's stock HttpResponseFeature treats OnStarting callbacks as no-ops because + /// the response is never actually started in a unit test. This replacement captures the + /// callbacks so the test can flush them after the downstream pipeline has set its response. + /// + private sealed class CapturingResponseFeature : HttpResponseFeature + { + private readonly List<(Func Callback, object State)> _onStartingCallbacks = []; + + public override void OnStarting(Func callback, object state) + { + _onStartingCallbacks.Add((callback, state)); + } + + public override void OnCompleted(Func callback, object state) + { + } + + public async Task TriggerOnStartingAsync() + { + foreach (var (callback, state) in _onStartingCallbacks) + { + await callback(state); + } + } + } +} + +internal sealed class RefreshStubAppGatewayApplicationFactory : WebApplicationFactory +{ + private static string _refreshToken = string.Empty; + private static string _accessToken = string.Empty; + private static string? _revokedReason; + + public static void SetStubResponse(string refreshToken, string accessToken) + { + _refreshToken = refreshToken; + _accessToken = accessToken; + _revokedReason = null; + } + + public static void SetStubRevoked(string revokedReason) + { + _revokedReason = revokedReason; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); + builder.ConfigureServices(services => { services.AddHttpClient("Account").ConfigurePrimaryHttpMessageHandler(() => new StubRefreshHandler()); } + ); + } + + private sealed class StubRefreshHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_revokedReason is not null) + { + var revoked = new HttpResponseMessage(HttpStatusCode.Unauthorized); + revoked.Headers.Add(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, _revokedReason); + return Task.FromResult(revoked); + } + + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Headers.Add(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, _refreshToken); + response.Headers.Add(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, _accessToken); + return Task.FromResult(response); + } + } } diff --git a/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs b/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs deleted file mode 100644 index 5098d0afc..000000000 --- a/application/AppGateway.Tests/UserFeatureFlagsResponseTransformTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Net; -using System.Security.Claims; -using AppGateway.Transformations; -using FluentAssertions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using SharedKernel.Authentication; -using SharedKernel.Authentication.TokenSigning; -using Xunit; -using Yarp.ReverseProxy.Transforms; - -namespace AppGateway.Tests; - -public sealed class UserFeatureFlagsResponseTransformTests(AppGatewayApplicationFactory factory) : IClassFixture -{ - [Fact] - public async Task ApplyAsync_WhenAccessTokenStashedWithFeatureFlagsClaim_ShouldEmitHeaderWithKeys() - { - // Arrange - var transform = factory.Services.GetRequiredService(); - var signingClient = factory.Services.GetRequiredService(); - var accessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "custom-branding,compact-view")]); - - var context = new DefaultHttpContext - { - Response = { Body = new MemoryStream() }, - Items = { [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken } - }; - var transformContext = new ResponseTransformContext { HttpContext = context }; - - // Act - await transform.ApplyAsync(transformContext); - - // Assert - context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("custom-branding,compact-view"); - } - - [Fact] - public async Task ApplyAsync_WhenAccessTokenHasNoFeatureFlagsClaim_ShouldEmitEmptyHeader() - { - // Arrange - var transform = factory.Services.GetRequiredService(); - var signingClient = factory.Services.GetRequiredService(); - var accessToken = CreateSignedToken(signingClient, []); - - var context = new DefaultHttpContext - { - Response = { Body = new MemoryStream() }, - Items = { [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken } - }; - var transformContext = new ResponseTransformContext { HttpContext = context }; - - // Act - await transform.ApplyAsync(transformContext); - - // Assert - context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be(string.Empty); - } - - [Fact] - public async Task ApplyAsync_WhenNoAccessTokenStashed_ShouldNotEmitHeader() - { - // Arrange - var transform = factory.Services.GetRequiredService(); - - var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; - var transformContext = new ResponseTransformContext { HttpContext = context }; - - // Act - await transform.ApplyAsync(transformContext); - - // Assert - context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); - } - - [Fact] - public async Task ApplyAsync_WhenUpstreamSetsRefreshAuthenticationTokensHeader_ShouldRefreshAndEmitPostRefreshHeader() - { - // Regression net for the prior split-design race: the transform read the pre-refresh access - // token before OnStarting swapped cookies, so the response header reflected stale flag state. - // Consolidating cookie-swap into the transform must produce a header that matches the - // post-refresh claim set on the SAME response. - await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); - var transform = stubFactory.Services.GetRequiredService(); - var signingClient = stubFactory.Services.GetRequiredService(); - - var inboundRefreshToken = CreateSignedToken(signingClient, []); - var preRefreshAccessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "stale-flag")]); - var postRefreshAccessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "custom-branding")]); - var postRefreshRefreshToken = CreateSignedToken(signingClient, []); - RefreshStubAppGatewayApplicationFactory.SetStubResponse(postRefreshRefreshToken, postRefreshAccessToken); - - var context = new DefaultHttpContext - { - Response = { Body = new MemoryStream() }, - Items = - { - [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = preRefreshAccessToken, - [UserFeatureFlagsResponseTransform.InboundRefreshTokenItemKey] = inboundRefreshToken - } - }; - context.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; - var transformContext = new ResponseTransformContext { HttpContext = context }; - - await transform.ApplyAsync(transformContext); - - context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("custom-branding"); - context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); - var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); - setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.RefreshTokenCookieName)); - setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.AccessTokenCookieName)); - } - - [Fact] - public async Task ApplyAsync_WhenRefreshEndpointSignalsSessionRevoked_ShouldOverwriteResponseWith401AndClearCookies() - { - // The refresh endpoint can detect a revoked session (e.g. token replay) and return 401 with - // x-unauthorized-reason. The transform must surface that to the SPA, not silently let the - // upstream success bleed through with the original cookies still valid. - await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); - var transform = stubFactory.Services.GetRequiredService(); - var signingClient = stubFactory.Services.GetRequiredService(); - - var inboundRefreshToken = CreateSignedToken(signingClient, []); - var preRefreshAccessToken = CreateSignedToken(signingClient, [new Claim("feature_flags", "stale-flag")]); - RefreshStubAppGatewayApplicationFactory.SetStubRevoked("ReplayAttackDetected"); - - var context = new DefaultHttpContext - { - Response = { Body = new MemoryStream(), StatusCode = StatusCodes.Status200OK }, - Items = - { - [UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = preRefreshAccessToken, - [UserFeatureFlagsResponseTransform.InboundRefreshTokenItemKey] = inboundRefreshToken - } - }; - context.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; - var transformContext = new ResponseTransformContext { HttpContext = context }; - - await transform.ApplyAsync(transformContext); - - context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey].ToString().Should().Be("ReplayAttackDetected"); - context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); - context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); - } - - private static string CreateSignedToken(ITokenSigningClient signingClient, Claim[] claims) - { - var now = DateTimeOffset.UtcNow; - var descriptor = new SecurityTokenDescriptor - { - NotBefore = now.UtcDateTime, - IssuedAt = now.UtcDateTime, - Expires = now.AddMinutes(5).UtcDateTime, - Issuer = signingClient.Issuer, - Audience = signingClient.Audience, - SigningCredentials = signingClient.GetSigningCredentials(), - Subject = new ClaimsIdentity(claims) - }; - return new JsonWebTokenHandler().CreateToken(descriptor); - } -} - -internal sealed class RefreshStubAppGatewayApplicationFactory : WebApplicationFactory -{ - private static string _refreshToken = string.Empty; - private static string _accessToken = string.Empty; - private static string? _revokedReason; - - public static void SetStubResponse(string refreshToken, string accessToken) - { - _refreshToken = refreshToken; - _accessToken = accessToken; - _revokedReason = null; - } - - public static void SetStubRevoked(string revokedReason) - { - _revokedReason = revokedReason; - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Development"); - builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); - builder.ConfigureServices(services => { services.AddHttpClient("Account").ConfigurePrimaryHttpMessageHandler(() => new StubRefreshHandler()); } - ); - } - - private sealed class StubRefreshHandler : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (_revokedReason is not null) - { - var revoked = new HttpResponseMessage(HttpStatusCode.Unauthorized); - revoked.Headers.Add(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, _revokedReason); - return Task.FromResult(revoked); - } - - var response = new HttpResponseMessage(HttpStatusCode.OK); - response.Headers.Add(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, _refreshToken); - response.Headers.Add(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, _accessToken); - return Task.FromResult(response); - } - } -} diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index 7238fa82d..80ce52e47 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Headers; -using AppGateway.Transformations; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using SharedKernel.Authentication; @@ -8,7 +7,7 @@ namespace AppGateway.Middleware; -public class AuthenticationCookieMiddleware( +public sealed class AuthenticationCookieMiddleware( ITokenSigningClient tokenSigningClient, IHttpClientFactory httpClientFactory, TimeProvider timeProvider, @@ -22,14 +21,18 @@ ILogger logger public async Task InvokeAsync(HttpContext context, RequestDelegate next) { + var tokenState = new TokenState(); + if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenFromCookie)) { - context.Items[UserFeatureFlagsResponseTransform.InboundRefreshTokenItemKey] = refreshTokenFromCookie; + tokenState.InboundRefreshToken = refreshTokenFromCookie; context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenCookieName, out var accessTokenCookieValue); - await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenFromCookie, accessTokenCookieValue); + tokenState.CurrentAccessToken = await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenFromCookie, accessTokenCookieValue); } - // If session was revoked during refresh, handle based on request type + // If session was revoked during the inbound cookie validation, short-circuit before reaching + // downstream. The OnStarting callback registered below will still emit x-user-feature-flags if + // a current token exists; in this revoked path we return early without a token, so no header. if (context.Items.TryGetValue(UnauthorizedReasonItemKey, out var reason) && reason is string unauthorizedReason) { if (context.Request.Path.StartsWithSegments("/api")) @@ -47,10 +50,81 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } + // Single OnStarting hook for everything driven by the downstream response: header-mode + // cookie swap (login / signup / switch-tenant), endpoint-triggered refresh + // (x-refresh-authentication-tokens-required), session-revoked 401 override, and + // x-user-feature-flags emission from the current (possibly just-refreshed) access token. + // Sequential execution in one hook eliminates the race the split YARP-transform design had. + context.Response.OnStarting(async state => + { + var (httpContext, currentState) = ((HttpContext, TokenState))state; + await HandleOutgoingResponseAsync(httpContext, currentState); + }, (context, tokenState) + ); + await next(context); } - private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(HttpContext context, string refreshToken, string? accessToken) + private async Task HandleOutgoingResponseAsync(HttpContext context, TokenState tokenState) + { + if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) + { + // Endpoint-triggered refresh: the downstream signaled the actor's claims have changed + // (e.g. PUT /me, PUT /me/change-locale, PUT /api/account/feature-flags/{key}/tenant-override). + // Refresh the JWT now so the same response carries fresh cookies AND a fresh x-user-feature-flags. + if (tokenState.InboundRefreshToken is { } refreshToken) + { + try + { + logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); + var (newRefreshToken, newAccessToken) = await RefreshAuthenticationTokensAsync(refreshToken); + await ReplaceAuthenticationHeaderWithCookieAsync(context, newRefreshToken, newAccessToken); + tokenState.CurrentAccessToken = newAccessToken; + } + catch (SessionRevokedException ex) + { + OverwriteWithUnauthorized(context, ex.RevokedReason); + logger.LogWarning(ex, "Session revoked during endpoint-triggered refresh. Reason: {Reason}", ex.RevokedReason); + return; + } + catch (SecurityTokenException ex) + { + OverwriteWithUnauthorized(context, nameof(UnauthorizedReason.SessionNotFound)); + logger.LogWarning(ex, "Endpoint-triggered token refresh failed validation. Path: {Path}", context.Request.Path); + return; + } + catch (HttpRequestException ex) + { + // Backend temporarily unreachable: the upstream mutation already succeeded, so + // let the response through. The SPA picks up new claims on the next refresh. + logger.LogWarning(ex, "Backend unavailable during endpoint-triggered refresh. Path: {Path}", context.Request.Path); + } + catch (TaskCanceledException ex) when (!context.RequestAborted.IsCancellationRequested) + { + logger.LogWarning(ex, "Backend timed out during endpoint-triggered refresh. Path: {Path}", context.Request.Path); + } + } + + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshTokenHeader) && + context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessTokenHeader)) + { + // Login / signup / switch-tenant flows return the new tokens in response headers + // for AppGateway to convert into cookies before egress. + var newRefreshToken = refreshTokenHeader.Single()!; + var newAccessToken = accessTokenHeader.Single()!; + await ReplaceAuthenticationHeaderWithCookieAsync(context, newRefreshToken, newAccessToken); + tokenState.CurrentAccessToken = newAccessToken; + } + + if (tokenState.CurrentAccessToken is { } currentAccessToken) + { + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(currentAccessToken); + } + } + + private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(HttpContext context, string refreshToken, string? accessToken) { if (context.Request.Headers.ContainsKey(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey) || context.Request.Headers.ContainsKey(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey)) @@ -69,7 +143,7 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, expiredCookieOptions); context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, expiredCookieOptions); logger.LogDebug("The refresh-token has expired; authentication token cookies are removed"); - return; + return null; } logger.LogDebug("The access-token has expired, attempting to refresh"); @@ -81,7 +155,7 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http } context.Request.Headers.Authorization = $"Bearer {accessToken}"; - context.Items[UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken; + return accessToken; } catch (SessionRevokedException ex) { @@ -113,6 +187,8 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http context.Items[UnauthorizedReasonItemKey] = nameof(UnauthorizedReason.SessionNotFound); logger.LogError(ex, "Unexpected exception during authentication token validation. Path: {Path}", context.Request.Path); } + + return null; } private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken) @@ -197,8 +273,6 @@ private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext contex context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); - - context.Items[UserFeatureFlagsResponseTransform.CurrentAccessTokenItemKey] = accessToken; } private async Task ExtractExpirationFromTokenAsync(string token) @@ -225,6 +299,37 @@ private async Task ExtractExpirationFromTokenAsync(string token) return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)); } + + private static string ExtractFeatureFlagsClaim(string accessToken) + { + if (!TokenHandler.CanReadToken(accessToken)) return string.Empty; + var jwt = TokenHandler.ReadJsonWebToken(accessToken); + return jwt.TryGetClaim("feature_flags", out var claim) ? claim.Value : string.Empty; + } + + /// + /// Convert the upstream success response into a 401 with x-unauthorized-reason so the + /// SPA's authentication middleware can react (redirect to login, surface revocation reason). + /// + private static void OverwriteWithUnauthorized(HttpContext context, string unauthorizedReason) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = unauthorizedReason; + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); + + var hostCookieOptions = new CookieOptions { Secure = true, Path = "/" }; + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); + } + + private sealed class TokenState + { + public string? CurrentAccessToken { get; set; } + + public string? InboundRefreshToken { get; set; } + } } public sealed class SessionRevokedException(string revokedReason) : SecurityTokenException($"Session has been revoked. Reason: {revokedReason}") diff --git a/application/AppGateway/Program.cs b/application/AppGateway/Program.cs index 83ab13ef8..ef848386a 100644 --- a/application/AppGateway/Program.cs +++ b/application/AppGateway/Program.cs @@ -38,8 +38,7 @@ .AddConfigFilter() .AddConfigFilter() .AddConfigFilter() - .AddTransforms(context => context.RequestTransforms.Add(context.Services.GetRequiredService())) - .AddTransforms(context => context.ResponseTransforms.Add(context.Services.GetRequiredService())); + .AddTransforms(context => context.RequestTransforms.Add(context.Services.GetRequiredService())); if (SharedInfrastructureConfiguration.IsRunningInAzure) { @@ -87,7 +86,6 @@ builder.Services .AddSingleton(SharedDependencyConfiguration.GetTokenSigningService()) .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddScoped(); diff --git a/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs b/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs deleted file mode 100644 index 26bbe01c0..000000000 --- a/application/AppGateway/Transformations/UserFeatureFlagsResponseTransform.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using AppGateway.Middleware; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using SharedKernel.Authentication; -using SharedKernel.Authentication.TokenSigning; -using Yarp.ReverseProxy.Transforms; - -namespace AppGateway.Transformations; - -/// -/// YARP response transform that consolidates the post-upstream authentication response handling: -/// swap any header-mode refresh/access tokens into cookies, perform the endpoint-triggered token -/// refresh when the upstream sets x-refresh-authentication-tokens-required, and emit -/// x-user-feature-flags from the (post-refresh) access-token claim. -/// A single hook eliminates the race the prior split design had: a separate OnStarting callback -/// refreshed the cookies AFTER this transform had already read the pre-refresh access token, -/// causing the actor's same-response header to reflect stale flag state. The transform runs -/// after the upstream response is received and before YARP calls Response.StartAsync, -/// so all mutations land on the same outgoing response. -/// -public sealed class UserFeatureFlagsResponseTransform( - IHttpClientFactory httpClientFactory, - ITokenSigningClient tokenSigningClient, - ILogger logger -) : ResponseTransform -{ - public const string CurrentAccessTokenItemKey = "CurrentAccessToken"; - public const string InboundRefreshTokenItemKey = "InboundRefreshToken"; - - private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account/authentication/refresh-authentication-tokens"; - - private static readonly JsonWebTokenHandler TokenHandler = new(); - - public override async ValueTask ApplyAsync(ResponseTransformContext context) - { - var httpContext = context.HttpContext; - - // Endpoint-triggered refresh: the downstream signaled the actor's claims have changed - // (e.g. PUT /me, PUT /me/change-locale, PUT /api/account/feature-flags/{key}/tenant-override). - // Refresh the JWT now so the same response carries fresh cookies AND a fresh x-user-feature-flags. - if (httpContext.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) - { - if (httpContext.Items.TryGetValue(InboundRefreshTokenItemKey, out var refreshItem) && refreshItem is string inboundRefreshToken) - { - try - { - logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); - var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(inboundRefreshToken); - await ReplaceAuthenticationHeaderWithCookieAsync(httpContext, refreshToken, accessToken); - } - catch (SessionRevokedException ex) - { - OverwriteWithUnauthorized(httpContext, ex.RevokedReason); - logger.LogWarning(ex, "Session revoked during endpoint-triggered refresh. Reason: {Reason}", ex.RevokedReason); - return; - } - catch (SecurityTokenException ex) - { - OverwriteWithUnauthorized(httpContext, nameof(UnauthorizedReason.SessionNotFound)); - logger.LogWarning(ex, "Endpoint-triggered token refresh failed validation. Path: {Path}", httpContext.Request.Path); - return; - } - catch (HttpRequestException ex) - { - // Backend temporarily unreachable: the upstream mutation already succeeded, so - // let the response through. The SPA picks up new claims on the next refresh. - logger.LogWarning(ex, "Backend unavailable during endpoint-triggered refresh. Path: {Path}", httpContext.Request.Path); - } - catch (TaskCanceledException ex) when (!httpContext.RequestAborted.IsCancellationRequested) - { - logger.LogWarning(ex, "Backend timed out during endpoint-triggered refresh. Path: {Path}", httpContext.Request.Path); - } - } - - httpContext.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); - } - else if (httpContext.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && - httpContext.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) - { - // Login / signup / switch-tenant flows return the new tokens in response headers - // for AppGateway to convert into cookies before egress. - await ReplaceAuthenticationHeaderWithCookieAsync(httpContext, refreshToken.Single()!, accessToken.Single()!); - } - - // Emit x-user-feature-flags from the current access token (post-refresh if a refresh just - // happened above, else the inbound token stashed by AuthenticationCookieMiddleware). - if (httpContext.Items.TryGetValue(CurrentAccessTokenItemKey, out var tokenItem) && tokenItem is string currentAccessToken) - { - httpContext.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = ExtractFeatureFlagsClaim(currentAccessToken); - } - } - - private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken) - { - var request = new HttpRequestMessage(HttpMethod.Post, RefreshAuthenticationTokensEndpoint); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", refreshToken); - - var accountHttpClient = httpClientFactory.CreateClient("Account"); - var response = await accountHttpClient.SendAsync(request); - - if (!response.IsSuccessStatusCode) - { - if (response.Headers.TryGetValues(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, out var reasons) && reasons.FirstOrDefault() is { } revokedReason) - { - throw new SessionRevokedException(revokedReason); - } - - if (response.StatusCode is HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout) - { - throw new HttpRequestException($"Backend temporarily unavailable. Status: {response.StatusCode}.", null, response.StatusCode); - } - - throw new SecurityTokenException($"Failed to refresh security tokens. Response status code: {response.StatusCode}."); - } - - var newRefreshToken = response.Headers.GetValues(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey).SingleOrDefault(); - var newAccessToken = response.Headers.GetValues(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey).SingleOrDefault(); - - if (newRefreshToken is null || newAccessToken is null) - { - throw new SecurityTokenException("Failed to get refreshed security tokens from the response."); - } - - return (newRefreshToken, newAccessToken); - } - - private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext context, string refreshToken, string accessToken) - { - var refreshTokenExpires = await ExtractExpirationFromTokenAsync(refreshToken); - - var refreshTokenCookieOptions = new CookieOptions - { - HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, Expires = refreshTokenExpires, Path = "/" - }; - context.Response.Cookies.Append(AuthenticationTokenHttpKeys.RefreshTokenCookieName, refreshToken, refreshTokenCookieOptions); - - var accessTokenCookieOptions = new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Path = "/" }; - context.Response.Cookies.Append(AuthenticationTokenHttpKeys.AccessTokenCookieName, accessToken, accessTokenCookieOptions); - - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); - - context.Items[CurrentAccessTokenItemKey] = accessToken; - } - - private async Task ExtractExpirationFromTokenAsync(string token) - { - if (!TokenHandler.CanReadToken(token)) - { - throw new SecurityTokenMalformedException("The token is not a valid JWT."); - } - - var validationParameters = tokenSigningClient.GetTokenValidationParameters( - validateLifetime: false, - clockSkew: TimeSpan.FromSeconds(2) - ); - - var validationResult = await TokenHandler.ValidateTokenAsync(token, validationParameters); - - if (!validationResult.IsValid) - { - throw validationResult.Exception; - } - - var expires = validationResult.Claims[JwtRegisteredClaimNames.Exp]?.ToString()!; - return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)); - } - - private static string ExtractFeatureFlagsClaim(string accessToken) - { - if (!TokenHandler.CanReadToken(accessToken)) return string.Empty; - var jwt = TokenHandler.ReadJsonWebToken(accessToken); - return jwt.TryGetClaim("feature_flags", out var claim) ? claim.Value : string.Empty; - } - - /// - /// Convert the upstream success response into a 401 with x-unauthorized-reason so the - /// SPA's authentication middleware can react (redirect to login, surface revocation reason). - /// Mirrors the pre-consolidation behavior in . - /// - private static void OverwriteWithUnauthorized(HttpContext context, string unauthorizedReason) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = unauthorizedReason; - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); - context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); - - var hostCookieOptions = new CookieOptions { Secure = true, Path = "/" }; - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); - } -} From d16610d9bd747115fd70f746841c48697dbe03ca Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 22:43:11 +0200 Subject: [PATCH 064/155] Verify tenant and user ownership before applying back-office feature flag overrides --- .../RemoveTenantFeatureFlagOverride.cs | 9 +- .../Commands/RemoveUserFeatureFlagOverride.cs | 9 +- .../Commands/SetTenantFeatureFlagInternal.cs | 9 +- .../Commands/SetUserFeatureFlagInternal.cs | 9 +- .../FeatureFlagBackOfficeTests.cs | 122 ++++++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs index 46b07c060..5c58f54b0 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveTenantFeatureFlagOverride.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -29,11 +30,17 @@ public RemoveTenantFeatureFlagOverrideValidator() } } -public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) +public sealed class RemoveTenantFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(RemoveTenantFeatureFlagOverrideCommand command, CancellationToken cancellationToken) { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant '{command.TenantId}' not found."); + } + var tenantOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, null, cancellationToken); if (tenantOverride is null) return Result.NotFound($"No tenant override found for flag '{command.FlagKey}' and tenant '{command.TenantId}'."); diff --git a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs index 88cc72af1..aead3b243 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/RemoveUserFeatureFlagOverride.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -30,11 +31,17 @@ public RemoveUserFeatureFlagOverrideValidator() } } -public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, ITelemetryEventsCollector events) +public sealed class RemoveUserFeatureFlagOverrideHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(RemoveUserFeatureFlagOverrideCommand command, CancellationToken cancellationToken) { + var user = await userRepository.GetByIdUnfilteredAsync(command.UserId, cancellationToken); + if (user is null || user.TenantId != command.TenantId) + { + return Result.NotFound($"User '{command.UserId}' not found in tenant '{command.TenantId}'."); + } + var userOverride = await featureFlagRepository.GetByKeyAndScopeAsync(command.FlagKey, command.TenantId, command.UserId, cancellationToken); if (userOverride is null) return Result.NotFound($"No user override found for flag '{command.FlagKey}' and user '{command.UserId}'."); diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs index ea16359bd..ad5c91816 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetTenantFeatureFlagInternal.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Tenants.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -31,11 +32,17 @@ public SetTenantFeatureFlagInternalValidator() } } -public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetTenantFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, ITenantRepository tenantRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetTenantFeatureFlagInternalCommand command, CancellationToken cancellationToken) { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant '{command.TenantId}' not found."); + } + var now = timeProvider.GetUtcNow(); if (command.Enabled) diff --git a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs index 4092897e6..e1956effb 100644 --- a/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs +++ b/application/account/Core/Features/FeatureFlags/Commands/SetUserFeatureFlagInternal.cs @@ -1,4 +1,5 @@ using Account.Features.FeatureFlags.Domain; +using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -32,11 +33,17 @@ public SetUserFeatureFlagInternalValidator() } } -public sealed class SetUserFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) +public sealed class SetUserFeatureFlagInternalHandler(IFeatureFlagRepository featureFlagRepository, IUserRepository userRepository, TimeProvider timeProvider, ITelemetryEventsCollector events) : IRequestHandler { public async Task Handle(SetUserFeatureFlagInternalCommand command, CancellationToken cancellationToken) { + var user = await userRepository.GetByIdUnfilteredAsync(command.UserId, cancellationToken); + if (user is null || user.TenantId != command.TenantId) + { + return Result.NotFound($"User '{command.UserId}' not found in tenant '{command.TenantId}'."); + } + var now = timeProvider.GetUtcNow(); if (command.Enabled) diff --git a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs index 5ff04b35c..49057cedb 100644 --- a/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs +++ b/application/account/Tests/BackOffice/FeatureFlags/FeatureFlagBackOfficeTests.cs @@ -338,6 +338,128 @@ public async Task RemoveUserOverride_WhenNoOverrideExists_ShouldReturnNotFound() response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + // User/tenant verification guards — the back-office can target any tenant, so handlers verify the + // (TenantId, UserId) pair exists before any repository write. A mismatched or non-existent target + // must short-circuit with NotFound, leaving no override row and no telemetry behind. + + [Fact] + public async Task SetUserFeatureFlagInternal_WhenUserDoesNotBelongToTenant_ShouldReturnNotFound() + { + // Arrange + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id; + var otherTenantId = new TenantId(999999); + using var client = CreateRegularBackOfficeClient(); + var command = new SetUserFeatureFlagInternalCommand { UserId = userId, TenantId = otherTenantId, Enabled = true }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/user-override", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"User '{userId}' not found in tenant '{otherTenantId}'."); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId = userId.Value }] + ); + rowCount.Should().Be(0, "the guard must run before any repository write"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveUserFeatureFlagOverride_WhenUserDoesNotBelongToTenant_ShouldReturnNotFound() + { + // Arrange - seed an override on the real tenant; the back-office request targets a different + // tenant so the guard must reject it without touching the existing override row. + var flagKey = "compact-view"; + var userId = DatabaseSeeder.Tenant1Owner.Id; + var realTenantId = DatabaseSeeder.Tenant1.Id; + var otherTenantId = new TenantId(999999); + InsertUserOverride(flagKey, realTenantId, userId.Value, true); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}/user-override?userId={userId}&tenantId={otherTenantId}"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"User '{userId}' not found in tenant '{otherTenantId}'."); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId = userId.Value }] + ); + rowCount.Should().Be(1, "the guard must run before any repository write"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task SetUserFeatureFlagInternal_WhenUserDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var flagKey = "compact-view"; + var missingUserId = UserId.NewId(); + var tenantId = DatabaseSeeder.Tenant1.Id; + using var client = CreateRegularBackOfficeClient(); + var command = new SetUserFeatureFlagInternalCommand { UserId = missingUserId, TenantId = tenantId, Enabled = true }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/user-override", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"User '{missingUserId}' not found in tenant '{tenantId}'."); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND user_id = @userId", + [new { flagKey, userId = missingUserId.Value }] + ); + rowCount.Should().Be(0, "the guard must run before any repository write"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task SetTenantFeatureFlagInternal_WhenTenantDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var flagKey = "beta-features"; + var missingTenantId = new TenantId(999999); + using var client = CreateRegularBackOfficeClient(); + var command = new SetTenantFeatureFlagInternalCommand { TenantId = missingTenantId, Enabled = true }; + + // Act + var response = await client.PutAsJsonAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"Tenant '{missingTenantId}' not found."); + + var rowCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM feature_flags WHERE flag_key = @flagKey AND tenant_id = @tenantId AND user_id IS NULL", + [new { flagKey, tenantId = missingTenantId.Value }] + ); + rowCount.Should().Be(0, "the guard must run before any repository write"); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveTenantFeatureFlagOverride_WhenTenantDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var flagKey = "beta-features"; + var missingTenantId = new TenantId(999999); + using var client = CreateRegularBackOfficeClient(); + + // Act + var response = await client.DeleteAsync($"/api/back-office/feature-flags/{flagKey}/tenant-override?tenantId={missingTenantId}"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"Tenant '{missingTenantId}' not found."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + // Rollout percentage (regular PolicyName per PP-1251 — not admin-tier) [Fact] From 74e575ceade6b2e584a435ec5031ed24a0712f2a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 May 2026 23:19:37 +0200 Subject: [PATCH 065/155] Collapse feature flag migrations and replace count-based rollout index with Postgres sequence --- ...60513132500_AddOrphanedAtToFeatureFlags.cs | 14 ---- ...75141_DropFeatureFlagVersionFromTenants.cs | 14 ---- ...645_AddFeatureFlagsForeignKeyAndIndexes.cs | 32 --------- ...> 20260513225500_AddFeatureFlagsSchema.cs} | 65 ++++++++++++++----- .../Domain/FeatureFlagRepository.cs | 7 ++ .../Features/Tenants/Commands/CreateTenant.cs | 4 +- .../Tenants/Domain/TenantRepository.cs | 31 +++++++-- .../Features/Users/Commands/CreateUser.cs | 4 +- .../Features/Users/Domain/UserRepository.cs | 31 +++++++-- 9 files changed, 108 insertions(+), 94 deletions(-) delete mode 100644 application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs delete mode 100644 application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs delete mode 100644 application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs rename application/account/Core/Database/Migrations/{20260405074500_AddFeatureFlags.cs => 20260513225500_AddFeatureFlagsSchema.cs} (59%) diff --git a/application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs b/application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs deleted file mode 100644 index c5f06d650..000000000 --- a/application/account/Core/Database/Migrations/20260513132500_AddOrphanedAtToFeatureFlags.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260513132500_AddOrphanedAtToFeatureFlags")] -public sealed class AddOrphanedAtToFeatureFlags : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn("orphaned_at", "feature_flags", "timestamptz", nullable: true); - } -} diff --git a/application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs b/application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs deleted file mode 100644 index a534007dc..000000000 --- a/application/account/Core/Database/Migrations/20260513175141_DropFeatureFlagVersionFromTenants.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260513175141_DropFeatureFlagVersionFromTenants")] -public sealed class DropFeatureFlagVersionFromTenants : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn("feature_flag_version", "tenants"); - } -} diff --git a/application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs b/application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs deleted file mode 100644 index 5ab60b76b..000000000 --- a/application/account/Core/Database/Migrations/20260513185645_AddFeatureFlagsForeignKeyAndIndexes.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260513185645_AddFeatureFlagsForeignKeyAndIndexes")] -public sealed class AddFeatureFlagsForeignKeyAndIndexes : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddForeignKey( - "fk_feature_flags_users_user_id", - "feature_flags", - "user_id", - "users", - principalColumn: "id", - onDelete: ReferentialAction.Cascade - ); - - // Indexes for the hot evaluator query paths. The pre-existing unique index leads on flag_key, which - // cannot serve the tenant-scoped / user-scoped lookups the evaluator runs. - migrationBuilder.CreateIndex("ix_feature_flags_tenant_id", "feature_flags", "tenant_id"); - migrationBuilder.CreateIndex("ix_feature_flags_user_id", "feature_flags", "user_id", filter: "user_id IS NOT NULL"); - migrationBuilder.CreateIndex( - "ix_feature_flags_tenant_id_source", - "feature_flags", - ["tenant_id", "source"], - filter: "source = 'Plan' AND user_id IS NULL" - ); - } -} diff --git a/application/account/Core/Database/Migrations/20260405074500_AddFeatureFlags.cs b/application/account/Core/Database/Migrations/20260513225500_AddFeatureFlagsSchema.cs similarity index 59% rename from application/account/Core/Database/Migrations/20260405074500_AddFeatureFlags.cs rename to application/account/Core/Database/Migrations/20260513225500_AddFeatureFlagsSchema.cs index 1dd3c4fa7..ce6117426 100644 --- a/application/account/Core/Database/Migrations/20260405074500_AddFeatureFlags.cs +++ b/application/account/Core/Database/Migrations/20260513225500_AddFeatureFlagsSchema.cs @@ -4,8 +4,8 @@ namespace Account.Database.Migrations; [DbContext(typeof(AccountDbContext))] -[Migration("20260405074500_AddFeatureFlags")] -public sealed class AddFeatureFlags : Migration +[Migration("20260513225500_AddFeatureFlagsSchema")] +public sealed class AddFeatureFlagsSchema : Migration { protected override void Up(MigrationBuilder migrationBuilder) { @@ -15,39 +15,54 @@ protected override void Up(MigrationBuilder migrationBuilder) { tenant_id = table.Column("bigint", nullable: true), id = table.Column("text", nullable: false), - flag_key = table.Column("text", nullable: false), user_id = table.Column("text", nullable: true), created_at = table.Column("timestamptz", nullable: false), modified_at = table.Column("timestamptz", nullable: true), + flag_key = table.Column("text", nullable: false), enabled_at = table.Column("timestamptz", nullable: true), disabled_at = table.Column("timestamptz", nullable: true), bucket_start = table.Column("integer", nullable: true), bucket_end = table.Column("integer", nullable: true), configurable_by_tenant = table.Column("boolean", nullable: false, defaultValue: false), configurable_by_user = table.Column("boolean", nullable: false, defaultValue: false), - source = table.Column("text", nullable: false, defaultValue: "Manual") + source = table.Column("text", nullable: false, defaultValue: "Manual"), + orphaned_at = table.Column("timestamptz", nullable: true) }, - constraints: table => { table.PrimaryKey("pk_feature_flags", x => x.id); } + constraints: table => + { + table.PrimaryKey("pk_feature_flags", x => x.id); + table.ForeignKey("fk_feature_flags_users_user_id", x => x.user_id, "users", "id", onDelete: ReferentialAction.Cascade); + } ); migrationBuilder.Sql( - "CREATE UNIQUE INDEX ix_feature_flags_flag_key_tenant_id_user_id ON feature_flags (flag_key, tenant_id, user_id) NULLS NOT DISTINCT" + "CREATE UNIQUE INDEX ix_feature_flags_flag_key_tenant_id_user_id ON feature_flags (flag_key, tenant_id, user_id) NULLS NOT DISTINCT;" + ); + + // Indexes for the hot evaluator query paths. The unique index above leads on flag_key, which + // cannot serve the tenant-scoped / user-scoped lookups the evaluator runs on every JWT refresh. + migrationBuilder.CreateIndex("ix_feature_flags_tenant_id", "feature_flags", "tenant_id"); + migrationBuilder.CreateIndex("ix_feature_flags_user_id", "feature_flags", "user_id", filter: "user_id IS NOT NULL"); + migrationBuilder.CreateIndex( + "ix_feature_flags_tenant_id_source", + "feature_flags", + ["tenant_id", "source"], + filter: "source = 'Plan' AND user_id IS NULL" ); migrationBuilder.Sql( - "ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_user_requires_tenant CHECK (user_id IS NULL OR tenant_id IS NOT NULL)" + "ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_user_requires_tenant CHECK (user_id IS NULL OR tenant_id IS NOT NULL);" ); migrationBuilder.Sql( """ ALTER TABLE feature_flags ADD CONSTRAINT ck_feature_flags_bucket_range - CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 0 AND 99 AND bucket_end BETWEEN 0 AND 99))) + CHECK ((bucket_start IS NULL) = (bucket_end IS NULL) AND (bucket_start IS NULL OR (bucket_start BETWEEN 0 AND 99 AND bucket_end BETWEEN 0 AND 99))); """ ); - migrationBuilder.AddColumn("feature_flag_version", "tenants", "integer", nullable: false, defaultValue: 0); - - // Add rollout_bucket to tenants and users, computed via van der Corput sequence + // Add rollout_bucket to tenants and users, computed via van der Corput sequence so existing rows + // are evenly spread across 0..99 for low-percent rollout fairness. migrationBuilder.AddColumn("rollout_bucket", "tenants", "smallint", nullable: true); migrationBuilder.AddColumn("rollout_bucket", "users", "smallint", nullable: true); @@ -77,11 +92,11 @@ WITH numbered AS ( FROM tenants ) UPDATE tenants SET rollout_bucket = van_der_corput_bucket(numbered.seq::integer) - FROM numbered WHERE tenants.id = numbered.id + FROM numbered WHERE tenants.id = numbered.id; """ ); - migrationBuilder.Sql("ALTER TABLE tenants ALTER COLUMN rollout_bucket SET NOT NULL"); + migrationBuilder.Sql("ALTER TABLE tenants ALTER COLUMN rollout_bucket SET NOT NULL;"); migrationBuilder.Sql( """ @@ -90,12 +105,30 @@ WITH numbered AS ( FROM users ) UPDATE users SET rollout_bucket = van_der_corput_bucket(numbered.seq::integer) - FROM numbered WHERE users.id = numbered.id + FROM numbered WHERE users.id = numbered.id; """ ); - migrationBuilder.Sql("ALTER TABLE users ALTER COLUMN rollout_bucket SET NOT NULL"); + migrationBuilder.Sql("ALTER TABLE users ALTER COLUMN rollout_bucket SET NOT NULL;"); + + migrationBuilder.Sql("DROP FUNCTION van_der_corput_bucket(integer);"); - migrationBuilder.Sql("DROP FUNCTION van_der_corput_bucket(integer)"); + // Postgres sequences hand out monotonically increasing values atomically across concurrent transactions; + // a COUNT(*)-based index would lose uniqueness under parallel CreateTenantCommand / CreateUserCommand. + // Seeded from the row count so the next index is strictly greater than any previously assigned, + // preserving the low-discrepancy bucket spread for already-created rows. + // SQLite (test database) has no sequence support; the repository falls back to COUNT(*) on that provider. + migrationBuilder.Sql( + "DO $$ DECLARE start_value bigint; BEGIN " + + "SELECT COUNT(*) + 1 INTO start_value FROM tenants; " + + "EXECUTE format('CREATE SEQUENCE IF NOT EXISTS tenant_rollout_index_sequence AS bigint START WITH %s', start_value); " + + "END $$;" + ); + migrationBuilder.Sql( + "DO $$ DECLARE start_value bigint; BEGIN " + + "SELECT COUNT(*) + 1 INTO start_value FROM users; " + + "EXECUTE format('CREATE SEQUENCE IF NOT EXISTS user_rollout_index_sequence AS bigint START WITH %s', start_value); " + + "END $$;" + ); } } diff --git a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs index 1c5d252bd..5e9215ab2 100644 --- a/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs +++ b/application/account/Core/Features/FeatureFlags/Domain/FeatureFlagRepository.cs @@ -5,6 +5,13 @@ namespace Account.Features.FeatureFlags.Domain; +/// +/// The aggregate intentionally does not implement ITenantScopedEntity: +/// a single physical table stores base rows (tenant_id IS NULL), tenant overrides, and user +/// overrides, so the global tenant query filter cannot be applied. Every method on this repository +/// reads or writes rows across multiple tenant scopes by design - never compare to other repositories +/// where UnfilteredAsync suffixes flag rare exceptions. +/// public interface IFeatureFlagRepository : ICrudRepository { Task GetAllRelevantRowsAsync(TenantId tenantId, UserId userId, CancellationToken cancellationToken); diff --git a/application/account/Core/Features/Tenants/Commands/CreateTenant.cs b/application/account/Core/Features/Tenants/Commands/CreateTenant.cs index 85c3d03f6..94e0397b4 100644 --- a/application/account/Core/Features/Tenants/Commands/CreateTenant.cs +++ b/application/account/Core/Features/Tenants/Commands/CreateTenant.cs @@ -18,8 +18,8 @@ internal sealed class CreateTenantHandler(ITenantRepository tenantRepository, IS { public async Task> Handle(CreateTenantCommand command, CancellationToken cancellationToken) { - var existingCount = await tenantRepository.GetCountUnfilteredAsync(cancellationToken); - var tenant = Tenant.Create(command.OwnerEmail, existingCount); + var rolloutIndex = await tenantRepository.GetNextRolloutIndexUnfilteredAsync(cancellationToken); + var tenant = Tenant.Create(command.OwnerEmail, rolloutIndex); await tenantRepository.AddAsync(tenant, cancellationToken); var subscription = Subscription.Create(tenant.Id); diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 3af8c26e9..03d50b9bc 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -57,10 +57,13 @@ public interface ITenantRepository : ICrudRepository, ISoftDel Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken); /// - /// Retrieves the total count of tenants without applying query filters. - /// This method is used to compute rollout buckets for new tenants. + /// Returns the next monotonic rollout index for a new tenant, used by the low-discrepancy bucket + /// function in to assign a well-spread rollout bucket. The Postgres sequence + /// guarantees uniqueness under concurrent CreateTenantCommand calls; SQLite (test database) falls + /// back to COUNT(*) which preserves the previous behavior. + /// Tenant query filter is ignored because tenant creation predates tenant context. /// - Task GetCountUnfilteredAsync(CancellationToken cancellationToken); + Task GetNextRolloutIndexUnfilteredAsync(CancellationToken cancellationToken); } public sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -167,11 +170,25 @@ public async Task GetMostRecentSignupsUnfilteredAsync(int limit, Cance } /// - /// Retrieves the total count of tenants without applying query filters. - /// This method is used to compute rollout buckets for new tenants. + /// Returns the next monotonic rollout index for a new tenant, used by the low-discrepancy bucket + /// function in to assign a well-spread rollout bucket. The Postgres sequence + /// guarantees uniqueness under concurrent CreateTenantCommand calls; SQLite (test database) falls + /// back to COUNT(*) which preserves the previous behavior. + /// Tenant query filter is ignored because tenant creation predates tenant context. /// - public async Task GetCountUnfilteredAsync(CancellationToken cancellationToken) + public async Task GetNextRolloutIndexUnfilteredAsync(CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(cancellationToken); + if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(cancellationToken); + } + + var nextSequenceValue = await accountDbContext.Database + .SqlQueryRaw("SELECT nextval('tenant_rollout_index_sequence') AS \"Value\"") + .SingleAsync(cancellationToken); + + // Sequence is seeded at COUNT(*)+1, so the first nextval after migration equals the previous COUNT+1. + // Subtracting 1 preserves the original count-based contract used by Tenant.Create. + return (int)(nextSequenceValue - 1); } } diff --git a/application/account/Core/Features/Users/Commands/CreateUser.cs b/application/account/Core/Features/Users/Commands/CreateUser.cs index 91b4f0040..23a13c586 100644 --- a/application/account/Core/Features/Users/Commands/CreateUser.cs +++ b/application/account/Core/Features/Users/Commands/CreateUser.cs @@ -48,8 +48,8 @@ public async Task> Handle(CreateUserCommand command, Cancellation var locale = SinglePageAppConfiguration.SupportedLocalizations.Contains(command.PreferredLocale) ? command.PreferredLocale : string.Empty; - var existingCount = await userRepository.GetCountUnfilteredAsync(cancellationToken); - var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale, existingCount); + var rolloutIndex = await userRepository.GetNextRolloutIndexUnfilteredAsync(cancellationToken); + var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale, rolloutIndex); await userRepository.AddAsync(user, cancellationToken); var gravatar = await gravatarClient.GetGravatar(user.Id, user.Email, cancellationToken); diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 92ba9251c..1e060bb2a 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -118,10 +118,13 @@ CancellationToken cancellationToken Task GetAllUnfilteredAsync(CancellationToken cancellationToken); /// - /// Retrieves the total count of users without applying query filters. - /// This method is used to compute rollout buckets for new users. + /// Returns the next monotonic rollout index for a new user, used by the low-discrepancy bucket + /// function in to assign a well-spread rollout bucket. The Postgres sequence + /// guarantees uniqueness under concurrent CreateUserCommand calls; SQLite (test database) falls + /// back to COUNT(*) which preserves the previous behavior. + /// Tenant query filter is ignored because user creation can occur before tenant context is established. /// - Task GetCountUnfilteredAsync(CancellationToken cancellationToken); + Task GetNextRolloutIndexUnfilteredAsync(CancellationToken cancellationToken); } public sealed class UserRepository(AccountDbContext accountDbContext, IExecutionContext executionContext, TimeProvider timeProvider) @@ -559,12 +562,26 @@ public async Task GetAllUnfilteredAsync(CancellationToken cancellationTo } /// - /// Retrieves the total count of users without applying query filters. - /// This method is used to compute rollout buckets for new users. + /// Returns the next monotonic rollout index for a new user, used by the low-discrepancy bucket + /// function in to assign a well-spread rollout bucket. The Postgres sequence + /// guarantees uniqueness under concurrent CreateUserCommand calls; SQLite (test database) falls + /// back to COUNT(*) which preserves the previous behavior. + /// Tenant query filter is ignored because user creation can occur before tenant context is established. /// - public async Task GetCountUnfilteredAsync(CancellationToken cancellationToken) + public async Task GetNextRolloutIndexUnfilteredAsync(CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(cancellationToken); + if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(cancellationToken); + } + + var nextSequenceValue = await accountDbContext.Database + .SqlQueryRaw("SELECT nextval('user_rollout_index_sequence') AS \"Value\"") + .SingleAsync(cancellationToken); + + // Sequence is seeded at COUNT(*)+1, so the first nextval after migration equals the previous COUNT+1. + // Subtracting 1 preserves the original count-based contract used by User.Create. + return (int)(nextSequenceValue - 1); } /// From eec31b518398defae15dc41617846c0f573fafd3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 14 May 2026 00:21:24 +0200 Subject: [PATCH 066/155] Consolidate feature flag labels and translations into shared UI module --- .../-components/AccountFeatureFlagRow.tsx | 2 +- .../routes/feature-flags/$flagKey.tsx | 2 +- .../-components/FeatureFlagInfoSection.tsx | 2 +- .../-components/TenantOverrideRow.tsx | 2 +- .../-components/UserOverrideRow.tsx | 2 +- .../BackOffice/routes/feature-flags/index.tsx | 2 +- .../users/-components/UserFeatureFlagRow.tsx | 2 +- .../shared/translations/locale/da-DK.po | 48 --------------- .../shared/translations/locale/en-US.po | 48 --------------- .../settings/-components/FeaturesSection.tsx | 2 +- .../-components/BetaFeaturesSection.tsx | 2 +- .../shared/lib/api/featureFlagLabels.ts | 37 ------------ .../shared/translations/locale/da-DK.po | 18 ------ .../shared/translations/locale/en-US.po | 18 ------ .../tests/e2e/feature-flag-flows.spec.ts | 44 +++++++------- .../FeatureFlags/FeatureFlagDefinition.cs | 1 + .../SharedKernel/FeatureFlags/FeatureFlags.cs | 21 ++++--- .../featureFlags/featureFlags.generated.json | 58 +++++++++++++++++++ .../ui/featureFlags/labels.ts} | 14 ++++- .../ui/translations/locale/da-DK.po | 54 +++++++++++++++++ .../ui/translations/locale/en-US.po | 54 +++++++++++++++++ 21 files changed, 225 insertions(+), 208 deletions(-) delete mode 100644 application/account/WebApp/shared/lib/api/featureFlagLabels.ts create mode 100644 application/shared-webapp/ui/featureFlags/featureFlags.generated.json rename application/{account/BackOffice/routes/feature-flags/-components/flagLabels.ts => shared-webapp/ui/featureFlags/labels.ts} (74%) diff --git a/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx index 41e9a4869..1278940c9 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountFeatureFlagRow.tsx @@ -5,6 +5,7 @@ import { Button } from "@repo/ui/components/Button"; import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { getFeatureFlagName, getFeatureFlagSourceLabel } from "@repo/ui/featureFlags/labels"; import { Link } from "@tanstack/react-router"; import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; @@ -14,7 +15,6 @@ import type { components } from "@/shared/lib/api/client"; import { api, queryClient } from "@/shared/lib/api/client"; -import { getFeatureFlagName, getFeatureFlagSourceLabel } from "../../feature-flags/-components/flagLabels"; import { ScopeIcon } from "../../feature-flags/-components/ScopeIcon"; type TenantFeatureFlagInfo = components["schemas"]["TenantFeatureFlagInfo"]; diff --git a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx index 243b85846..60ed4e15b 100644 --- a/application/account/BackOffice/routes/feature-flags/$flagKey.tsx +++ b/application/account/BackOffice/routes/feature-flags/$flagKey.tsx @@ -4,6 +4,7 @@ import { AppLayout } from "@repo/ui/components/AppLayout"; import { Button } from "@repo/ui/components/Button"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; +import { getFeatureFlagDescription, getFeatureFlagName } from "@repo/ui/featureFlags/labels"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeftIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; @@ -16,7 +17,6 @@ import type { GetFeatureFlagsResponse } from "./-components/types"; import { DeleteFeatureFlagDialog } from "./-components/DeleteFeatureFlagDialog"; import { FeatureFlagInfoSection } from "./-components/FeatureFlagInfoSection"; -import { getFeatureFlagDescription, getFeatureFlagName } from "./-components/flagLabels"; import { OrphanedFeatureFlagBadge } from "./-components/OrphanedFeatureFlagBadge"; import { PlanFeatureFlagInfoSection, PlanFeatureFlagTenantsSection } from "./-components/PlanFeatureFlagSections"; import { ScopeIcon } from "./-components/ScopeIcon"; diff --git a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx index d2022d8e2..a315dd9e5 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/FeatureFlagInfoSection.tsx @@ -4,6 +4,7 @@ import { Badge } from "@repo/ui/components/Badge"; import { Switch } from "@repo/ui/components/Switch"; import { TextField } from "@repo/ui/components/TextField"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { getFeatureFlagName } from "@repo/ui/featureFlags/labels"; import { InfoIcon } from "lucide-react"; import { useRef, useState } from "react"; import { toast } from "sonner"; @@ -12,7 +13,6 @@ import { api } from "@/shared/lib/api/client"; import type { FeatureFlagInfo } from "./types"; -import { getFeatureFlagName } from "./flagLabels"; import { formatRolloutBucketRange } from "./rolloutBucket"; interface FeatureFlagInfoSectionProps { diff --git a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx index ad932db51..f3aea117c 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/TenantOverrideRow.tsx @@ -6,6 +6,7 @@ import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { getFeatureFlagSourceLabel } from "@repo/ui/featureFlags/labels"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { Link } from "@tanstack/react-router"; import { XIcon } from "lucide-react"; @@ -21,7 +22,6 @@ import type { FeatureFlagTenantInfo } from "./types"; import { MrrCell } from "../../accounts/-components/MrrCell"; import { TenantStatusBadge } from "../../accounts/-components/TenantStatusBadge"; import { getUserDisplayName } from "../../users/-components/userDisplay"; -import { getFeatureFlagSourceLabel } from "./flagLabels"; export function TenantOverrideRow({ flagKey, diff --git a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx index 3c6de689c..02ba7cb09 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx +++ b/application/account/BackOffice/routes/feature-flags/-components/UserOverrideRow.tsx @@ -6,6 +6,7 @@ import { Button } from "@repo/ui/components/Button"; import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { getFeatureFlagSourceLabel } from "@repo/ui/featureFlags/labels"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { Link } from "@tanstack/react-router"; import { MailIcon, XIcon } from "lucide-react"; @@ -18,7 +19,6 @@ import { getUserRoleLabel } from "@/shared/lib/api/labels"; import type { FeatureFlagUserInfo } from "./types"; import { getUserDisplayName, getUserInitials } from "../../users/-components/userDisplay"; -import { getFeatureFlagSourceLabel } from "./flagLabels"; export function UserOverrideRow({ flagKey, diff --git a/application/account/BackOffice/routes/feature-flags/index.tsx b/application/account/BackOffice/routes/feature-flags/index.tsx index 88aa513b0..950e95b0e 100644 --- a/application/account/BackOffice/routes/feature-flags/index.tsx +++ b/application/account/BackOffice/routes/feature-flags/index.tsx @@ -5,6 +5,7 @@ import { Badge } from "@repo/ui/components/Badge"; import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { getFeatureFlagDescription, getFeatureFlagName } from "@repo/ui/featureFlags/labels"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; @@ -13,7 +14,6 @@ import { api } from "@/shared/lib/api/client"; import type { FeatureFlagInfo, FeatureFlagScope, GetFeatureFlagsResponse } from "./-components/types"; -import { getFeatureFlagDescription, getFeatureFlagName } from "./-components/flagLabels"; import { ScopeIcon } from "./-components/ScopeIcon"; export const Route = createFileRoute("/feature-flags/")({ diff --git a/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx b/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx index fd3b94006..f1c7f9f09 100644 --- a/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx +++ b/application/account/BackOffice/routes/users/-components/UserFeatureFlagRow.tsx @@ -4,6 +4,7 @@ import { Button } from "@repo/ui/components/Button"; import { Switch } from "@repo/ui/components/Switch"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { getFeatureFlagName, getFeatureFlagSourceLabel } from "@repo/ui/featureFlags/labels"; import { Link } from "@tanstack/react-router"; import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; @@ -13,7 +14,6 @@ import type { components } from "@/shared/lib/api/client"; import { api, queryClient } from "@/shared/lib/api/client"; -import { getFeatureFlagName, getFeatureFlagSourceLabel } from "../../feature-flags/-components/flagLabels"; import { ScopeIcon } from "../../feature-flags/-components/ScopeIcon"; type UserFeatureFlagInfo = components["schemas"]["UserFeatureFlagInfo"]; diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 282bce627..d41990bdf 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -103,9 +103,6 @@ msgstr "7d" msgid "90d" msgstr "90d" -msgid "A/B rollout" -msgstr "A/B-udrulning" - msgid "Account" msgstr "Konto" @@ -183,9 +180,6 @@ msgstr "Samlet" msgid "All-time, excluding VAT" msgstr "Hele perioden, eksklusiv moms" -msgid "Allow users to authenticate using enterprise identity providers" -msgstr "Tillad brugere at logge ind via virksomhedsidentitetsudbydere" - msgid "Amount" msgstr "Beløb" @@ -215,9 +209,6 @@ msgstr "Tilbage til feature flags" msgid "Basis" msgstr "Basis" -msgid "Beta features" -msgstr "Betafunktioner" - msgid "Billing" msgstr "Fakturering" @@ -287,9 +278,6 @@ msgstr "Luk" msgid "Close account preview" msgstr "Luk kontoforhåndsvisning" -msgid "Compact view" -msgstr "Kompakt visning" - msgid "Contact your administrator." msgstr "Kontakt din administrator." @@ -313,12 +301,6 @@ msgstr "Aktuel periode" msgid "Current plan" msgstr "Aktuelt abonnement" -msgid "Custom branding" -msgstr "Tilpasset branding" - -msgid "Customize the login page with your organization's logo and colors" -msgstr "Tilpas login-siden med din organisations logo og farver" - msgid "Dark" msgstr "Mørk" @@ -375,9 +357,6 @@ msgstr "Afvigelser fundet" msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." msgstr "Hver konto eller bruger tildeles en fast bucket (0-99) baseret på deres sekvensnummer. Udrulningen rammer et specifikt interval af buckets, hvilket sikrer en konsistent og forudsigelig udrulning." -msgid "Early access to experimental features before general availability" -msgstr "Tidlig adgang til eksperimentelle funktioner før generel tilgængelighed" - msgid "Email" msgstr "E-mail" @@ -420,9 +399,6 @@ msgstr "Alle login-forsøg de seneste 30 dage, succesfulde eller mislykkede, på msgid "Exit kiosk mode" msgstr "Forlad kiosktilstand" -msgid "Experimental UI" -msgstr "Eksperimentel UI" - msgid "Expired" msgstr "Udløbet" @@ -462,9 +438,6 @@ msgstr "Gå til forsiden" msgid "Google" msgstr "Google" -msgid "Google OAuth" -msgstr "Google OAuth" - msgid "Has override" msgstr "Med tilsidesættelse" @@ -552,9 +525,6 @@ msgstr "Logo" msgid "Main navigation" msgstr "Hovednavigation" -msgid "Manual override" -msgstr "Manuel tilsidesættelse" - msgid "Member" msgstr "Medlem" @@ -920,9 +890,6 @@ msgstr "Afstemmer..." msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Posteringer vises her, når konti abonnerer, og Stripe-webhooks behandles." -msgid "Reduce spacing between UI elements for a denser layout" -msgstr "Reducér afstanden mellem UI-elementer for et tættere layout" - msgid "Refunded" msgstr "Refunderet" @@ -1043,9 +1010,6 @@ msgstr "Sessioner" msgid "Show details" msgstr "Vis detaljer" -msgid "Sign in with Google using OpenID Connect" -msgstr "Log ind med Google via OpenID Connect" - msgid "Signed up" msgstr "Tilmeldt" @@ -1058,9 +1022,6 @@ msgstr "Tilmeldt <0>{0}<1>{1}" msgid "Since {0}" msgstr "Siden {0}" -msgid "Single sign-on" -msgstr "Single sign-on" - msgid "Small" msgstr "Lille" @@ -1079,9 +1040,6 @@ msgstr "Tilstand" msgid "Status" msgstr "Status" -msgid "Stripe-powered subscription billing and plan management" -msgstr "Stripe-drevet abonnementsfakturering og abonnementsstyring" - msgid "Subscribed" msgstr "Tilmeldt" @@ -1097,9 +1055,6 @@ msgstr "Abonnements-, betalings- og faktureringsovergange vises her efterhånden msgid "Subscription, payment, and billing transitions will appear here." msgstr "Abonnements-, betalings- og faktureringsovergange vises her." -msgid "Subscriptions" -msgstr "Abonnementer" - msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." @@ -1191,9 +1146,6 @@ msgstr "Prøv at rydde søgningen eller filtrene for at se flere resultater." msgid "Try clearing the search to see more results." msgstr "Prøv at rydde søgningen for at se flere resultater." -msgid "Try out experimental user interface components" -msgstr "Afprøv eksperimentelle brugergrænsefladekomponenter" - msgid "Unclassified" msgstr "Uklassificeret" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index ac32540d7..55bd3b81e 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -103,9 +103,6 @@ msgstr "7d" msgid "90d" msgstr "90d" -msgid "A/B rollout" -msgstr "A/B rollout" - msgid "Account" msgstr "Account" @@ -183,9 +180,6 @@ msgstr "All-time" msgid "All-time, excluding VAT" msgstr "All-time, excluding VAT" -msgid "Allow users to authenticate using enterprise identity providers" -msgstr "Allow users to authenticate using enterprise identity providers" - msgid "Amount" msgstr "Amount" @@ -215,9 +209,6 @@ msgstr "Back to feature flags" msgid "Basis" msgstr "Basis" -msgid "Beta features" -msgstr "Beta features" - msgid "Billing" msgstr "Billing" @@ -287,9 +278,6 @@ msgstr "Close" msgid "Close account preview" msgstr "Close account preview" -msgid "Compact view" -msgstr "Compact view" - msgid "Contact your administrator." msgstr "Contact your administrator." @@ -313,12 +301,6 @@ msgstr "Current period" msgid "Current plan" msgstr "Current plan" -msgid "Custom branding" -msgstr "Custom branding" - -msgid "Customize the login page with your organization's logo and colors" -msgstr "Customize the login page with your organization's logo and colors" - msgid "Dark" msgstr "Dark" @@ -375,9 +357,6 @@ msgstr "Drift detected" msgid "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." msgstr "Each account or user is assigned a fixed bucket (0-99) based on their sequence number. The rollout targets a specific range of buckets, ensuring consistent and predictable feature rollout." -msgid "Early access to experimental features before general availability" -msgstr "Early access to experimental features before general availability" - msgid "Email" msgstr "Email" @@ -420,9 +399,6 @@ msgstr "Every sign-in attempt over the last 30 days, successful or failed, acros msgid "Exit kiosk mode" msgstr "Exit kiosk mode" -msgid "Experimental UI" -msgstr "Experimental UI" - msgid "Expired" msgstr "Expired" @@ -462,9 +438,6 @@ msgstr "Go to home" msgid "Google" msgstr "Google" -msgid "Google OAuth" -msgstr "Google OAuth" - msgid "Has override" msgstr "Has override" @@ -552,9 +525,6 @@ msgstr "Logo" msgid "Main navigation" msgstr "Main navigation" -msgid "Manual override" -msgstr "Manual override" - msgid "Member" msgstr "Member" @@ -920,9 +890,6 @@ msgstr "Reconciling..." msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." msgstr "Records will appear here as accounts subscribe and Stripe webhooks are processed." -msgid "Reduce spacing between UI elements for a denser layout" -msgstr "Reduce spacing between UI elements for a denser layout" - msgid "Refunded" msgstr "Refunded" @@ -1043,9 +1010,6 @@ msgstr "Sessions" msgid "Show details" msgstr "Show details" -msgid "Sign in with Google using OpenID Connect" -msgstr "Sign in with Google using OpenID Connect" - msgid "Signed up" msgstr "Signed up" @@ -1058,9 +1022,6 @@ msgstr "Signed up <0>{0}<1>{1}" msgid "Since {0}" msgstr "Since {0}" -msgid "Single sign-on" -msgstr "Single sign-on" - msgid "Small" msgstr "Small" @@ -1079,9 +1040,6 @@ msgstr "State" msgid "Status" msgstr "Status" -msgid "Stripe-powered subscription billing and plan management" -msgstr "Stripe-powered subscription billing and plan management" - msgid "Subscribed" msgstr "Subscribed" @@ -1097,9 +1055,6 @@ msgstr "Subscription, payment, and billing transitions will appear here as Strip msgid "Subscription, payment, and billing transitions will appear here." msgstr "Subscription, payment, and billing transitions will appear here." -msgid "Subscriptions" -msgstr "Subscriptions" - msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Subscriptions, upgrades, and cancellations will appear here." @@ -1191,9 +1146,6 @@ msgstr "Try clearing the search or filters to see more results." msgid "Try clearing the search to see more results." msgstr "Try clearing the search to see more results." -msgid "Try out experimental user interface components" -msgstr "Try out experimental user interface components" - msgid "Unclassified" msgstr "Unclassified" diff --git a/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx b/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx index 7ef552609..d06161cc8 100644 --- a/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx +++ b/application/account/WebApp/routes/account/settings/-components/FeaturesSection.tsx @@ -3,11 +3,11 @@ import { Trans } from "@lingui/react/macro"; import { Separator } from "@repo/ui/components/Separator"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { Switch } from "@repo/ui/components/Switch"; +import { getFeatureFlagLabel } from "@repo/ui/featureFlags/labels"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { api } from "@/shared/lib/api/client"; -import { getFeatureFlagLabel } from "@/shared/lib/api/featureFlagLabels"; interface TenantFlag { flagKey: string; diff --git a/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx b/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx index 40bf5ee12..0437b49e6 100644 --- a/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx +++ b/application/account/WebApp/routes/user/preferences/-components/BetaFeaturesSection.tsx @@ -2,11 +2,11 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { Switch } from "@repo/ui/components/Switch"; +import { getFeatureFlagLabel } from "@repo/ui/featureFlags/labels"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { api } from "@/shared/lib/api/client"; -import { getFeatureFlagLabel } from "@/shared/lib/api/featureFlagLabels"; interface UserFlag { flagKey: string; diff --git a/application/account/WebApp/shared/lib/api/featureFlagLabels.ts b/application/account/WebApp/shared/lib/api/featureFlagLabels.ts deleted file mode 100644 index 7a6b1ffc2..000000000 --- a/application/account/WebApp/shared/lib/api/featureFlagLabels.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { t } from "@lingui/core/macro"; - -interface FeatureFlagLabel { - name: string; - description: string; -} - -function getKnownFlagLabels(): Record { - return { - "custom-branding": { - name: t`Custom branding`, - description: t`Enable branded login page` - }, - "compact-view": { - name: t`Compact view`, - description: t`Use a more compact layout` - }, - "experimental-ui": { - name: t`Experimental UI`, - description: t`Try experimental UI components` - } - }; -} - -function formatFlagKey(flagKey: string): string { - const formatted = flagKey.replace(/-/g, " "); - return formatted.charAt(0).toUpperCase() + formatted.slice(1); -} - -export function getFeatureFlagLabel(flagKey: string): FeatureFlagLabel { - const known = getKnownFlagLabels()[flagKey]; - if (known) { - return known; - } - const name = formatFlagKey(flagKey); - return { name, description: name }; -} diff --git a/application/account/WebApp/shared/translations/locale/da-DK.po b/application/account/WebApp/shared/translations/locale/da-DK.po index 277ece523..458046c2b 100644 --- a/application/account/WebApp/shared/translations/locale/da-DK.po +++ b/application/account/WebApp/shared/translations/locale/da-DK.po @@ -717,9 +717,6 @@ msgstr "Command" msgid "Command palette" msgstr "Kommandopalette" -msgid "Compact view" -msgstr "Kompakt visning" - msgid "Complete your payment to activate your subscription." msgstr "Gennemfør din betaling for at aktivere dit abonnement." @@ -816,9 +813,6 @@ msgstr "Nuværende side" msgid "Current plan" msgstr "Nuværende plan" -msgid "Custom branding" -msgstr "Brugerdefineret branding" - msgid "Customer deleted" msgstr "Kunde slettet" @@ -1084,9 +1078,6 @@ msgstr "Tom" msgid "Empty recycle bin" msgstr "Tøm papirkurv" -msgid "Enable branded login page" -msgstr "Aktivér branded loginside" - msgid "Enabled" msgstr "Aktiveret" @@ -1135,9 +1126,6 @@ msgstr "Fold Komponenter ud" msgid "Expand Examples" msgstr "Fold Eksempler ud" -msgid "Experimental UI" -msgstr "Eksperimentel brugerflade" - msgid "expires" msgstr "udløber" @@ -2762,9 +2750,6 @@ msgstr "Prøv at justere din søgning eller filtre" msgid "Try again" msgstr "Prøv igen" -msgid "Try experimental UI components" -msgstr "Prøv eksperimentelle brugerfladekomponenter" - msgid "Two-factor authentication" msgstr "Tofaktor-godkendelse" @@ -2850,9 +2835,6 @@ msgstr "Upload profilbillede" msgid "Use a horizontal separator to group related sections in a column layout." msgstr "Brug en vandret separator til at gruppere relaterede sektioner i en kolonnelayout." -msgid "Use a more compact layout" -msgstr "Brug et mere kompakt layout" - msgid "Use Accordion to stack multiple related disclosable sections. Good for FAQs, settings groups, or any list where users scan items and expand a few." msgstr "Brug Accordion til at stable flere relaterede sektioner. God til FAQs, indstillingsgrupper eller lister hvor brugere scanner elementer og udvider et par stykker." diff --git a/application/account/WebApp/shared/translations/locale/en-US.po b/application/account/WebApp/shared/translations/locale/en-US.po index 242508db3..73dbd669b 100644 --- a/application/account/WebApp/shared/translations/locale/en-US.po +++ b/application/account/WebApp/shared/translations/locale/en-US.po @@ -717,9 +717,6 @@ msgstr "Command" msgid "Command palette" msgstr "Command palette" -msgid "Compact view" -msgstr "Compact view" - msgid "Complete your payment to activate your subscription." msgstr "Complete your payment to activate your subscription." @@ -816,9 +813,6 @@ msgstr "Current page" msgid "Current plan" msgstr "Current plan" -msgid "Custom branding" -msgstr "Custom branding" - msgid "Customer deleted" msgstr "Customer deleted" @@ -1084,9 +1078,6 @@ msgstr "Empty" msgid "Empty recycle bin" msgstr "Empty recycle bin" -msgid "Enable branded login page" -msgstr "Enable branded login page" - msgid "Enabled" msgstr "Enabled" @@ -1135,9 +1126,6 @@ msgstr "Expand Components" msgid "Expand Examples" msgstr "Expand Examples" -msgid "Experimental UI" -msgstr "Experimental UI" - msgid "expires" msgstr "expires" @@ -2762,9 +2750,6 @@ msgstr "Try adjusting your search or filters" msgid "Try again" msgstr "Try again" -msgid "Try experimental UI components" -msgstr "Try experimental UI components" - msgid "Two-factor authentication" msgstr "Two-factor authentication" @@ -2850,9 +2835,6 @@ msgstr "Upload profile picture" msgid "Use a horizontal separator to group related sections in a column layout." msgstr "Use a horizontal separator to group related sections in a column layout." -msgid "Use a more compact layout" -msgstr "Use a more compact layout" - msgid "Use Accordion to stack multiple related disclosable sections. Good for FAQs, settings groups, or any list where users scan items and expand a few." msgstr "Use Accordion to stack multiple related disclosable sections. Good for FAQs, settings groups, or any list where users scan items and expand a few." diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index e05439c6b..9048b3fb4 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -89,24 +89,28 @@ test.describe("@smoke", () => { expect(response.ok()).toBe(true); })(); - await step( - "Switch to the All state filter and search by Test Organization & verify a matching row renders" - )(async () => { - // Switch to the All filter so the row set is independent of cross-worker rollout-percentage races - // on the shared beta-features global state. Without this, parallel test runs from other browser - // workers can flip rollout to 0 mid-test, emptying the default Enabled view. - await page.getByRole("group", { name: "State" }).getByRole("button", { name: "All" }).click(); - await page.getByRole("searchbox", { name: "Search" }).fill("Test Organization"); - - // Wait for the search-debounce query to settle AND for a matching row to render. Without the row - // assertion, downstream `.first()` targeting can bind to a row from a parallel test's tenant - // ("Mobile Nav Test" etc.) that happens to slip through the search match, or to a stale row - // from the pre-debounce result set that becomes detached when the new query resolves. - await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); - await expect( - page.getByRole("table", { name: "Accounts" }).getByRole("row").filter({ hasText: "Test Organization" }).first() - ).toBeVisible(); - })(); + await step("Switch to the All state filter and search by Test Organization & verify a matching row renders")( + async () => { + // Switch to the All filter so the row set is independent of cross-worker rollout-percentage races + // on the shared beta-features global state. Without this, parallel test runs from other browser + // workers can flip rollout to 0 mid-test, emptying the default Enabled view. + await page.getByRole("group", { name: "State" }).getByRole("button", { name: "All" }).click(); + await page.getByRole("searchbox", { name: "Search" }).fill("Test Organization"); + + // Wait for the search-debounce query to settle AND for a matching row to render. Without the row + // assertion, downstream `.first()` targeting can bind to a row from a parallel test's tenant + // ("Mobile Nav Test" etc.) that happens to slip through the search match, or to a stale row + // from the pre-debounce result set that becomes detached when the new query resolves. + await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); + await expect( + page + .getByRole("table", { name: "Accounts" }) + .getByRole("row") + .filter({ hasText: "Test Organization" }) + .first() + ).toBeVisible(); + } + )(); await step("Toggle the first Test Organization override & verify toast confirms state change")(async () => { const testOrgRow = page @@ -387,9 +391,7 @@ test.describe("@comprehensive", () => { await searchBox.fill("Test Organization"); await expect(page).toHaveURL((url) => url.searchParams.get("tenantsSearch") === "Test Organization"); - await expect( - accountsTable.getByRole("row").filter({ hasText: "Test Organization" }).first() - ).toBeVisible(); + await expect(accountsTable.getByRole("row").filter({ hasText: "Test Organization" }).first()).toBeVisible(); } )(); diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs index c5007c62b..5cf11c3db 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlagDefinition.cs @@ -7,6 +7,7 @@ public sealed record FeatureFlagDefinition( string Key, FeatureFlagScope Scope, FeatureFlagAdminLevel AdminLevel, + string Label, string Description, string? ParentDependency = null, bool IsAbTestEligible = false, diff --git a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs index bb9447ed7..b35e38469 100644 --- a/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs +++ b/application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs @@ -7,7 +7,8 @@ public static class FeatureFlags "google-oauth", FeatureFlagScope.System, FeatureFlagAdminLevel.SystemAdmin, - "Google OAuth authentication", + "Google OAuth", + "Sign in with Google using OpenID Connect", SystemConfigKey: "OAuth:Google:ClientId" ); @@ -15,7 +16,8 @@ public static class FeatureFlags "subscriptions", FeatureFlagScope.System, FeatureFlagAdminLevel.SystemAdmin, - "Subscription billing via Stripe", + "Subscriptions", + "Stripe-powered subscription billing and plan management", SystemConfigKey: "Stripe:SubscriptionEnabled", SystemConfigExpectedValue: "true" ); @@ -24,7 +26,8 @@ public static class FeatureFlags "beta-features", FeatureFlagScope.Tenant, FeatureFlagAdminLevel.SystemAdmin, - "Enables beta features for tenants", + "Beta features", + "Early access to experimental features before general availability", IsAbTestEligible: true, TrackInTelemetry: true, IsKillSwitchEnabled: true @@ -34,7 +37,8 @@ public static class FeatureFlags "sso", FeatureFlagScope.Tenant, FeatureFlagAdminLevel.SystemAdmin, - "Enables single sign-on for tenants", + "Single sign-on", + "Allow users to authenticate using enterprise identity providers", RequiredPlan: PlanTier.Premium, IsKillSwitchEnabled: false ); @@ -43,7 +47,8 @@ public static class FeatureFlags "custom-branding", FeatureFlagScope.Tenant, FeatureFlagAdminLevel.TenantOwner, - "Enables custom branding options for tenants", + "Custom branding", + "Customize the login page with your organization's logo and colors", ConfigurableByTenant: true, IsKillSwitchEnabled: false ); @@ -52,7 +57,8 @@ public static class FeatureFlags "compact-view", FeatureFlagScope.User, FeatureFlagAdminLevel.User, - "Enables compact view in the user interface", + "Compact view", + "Reduce spacing between UI elements for a denser layout", ConfigurableByUser: true, IsKillSwitchEnabled: false ); @@ -61,7 +67,8 @@ public static class FeatureFlags "experimental-ui", FeatureFlagScope.User, FeatureFlagAdminLevel.User, - "Enables experimental UI components for users", + "Experimental UI", + "Try out experimental user interface components", IsAbTestEligible: true, TrackInTelemetry: true, IsKillSwitchEnabled: true diff --git a/application/shared-webapp/ui/featureFlags/featureFlags.generated.json b/application/shared-webapp/ui/featureFlags/featureFlags.generated.json new file mode 100644 index 000000000..eb1295168 --- /dev/null +++ b/application/shared-webapp/ui/featureFlags/featureFlags.generated.json @@ -0,0 +1,58 @@ +[ + { + "Key": "google-oauth", + "Label": "Google OAuth", + "Description": "Sign in with Google using OpenID Connect", + "Scope": "System", + "AdminLevel": "SystemAdmin", + "RequiredPlan": null + }, + { + "Key": "subscriptions", + "Label": "Subscriptions", + "Description": "Stripe-powered subscription billing and plan management", + "Scope": "System", + "AdminLevel": "SystemAdmin", + "RequiredPlan": null + }, + { + "Key": "beta-features", + "Label": "Beta features", + "Description": "Early access to experimental features before general availability", + "Scope": "Tenant", + "AdminLevel": "SystemAdmin", + "RequiredPlan": null + }, + { + "Key": "sso", + "Label": "Single sign-on", + "Description": "Allow users to authenticate using enterprise identity providers", + "Scope": "Tenant", + "AdminLevel": "SystemAdmin", + "RequiredPlan": "Premium" + }, + { + "Key": "custom-branding", + "Label": "Custom branding", + "Description": "Customize the login page with your organization\u0027s logo and colors", + "Scope": "Tenant", + "AdminLevel": "TenantOwner", + "RequiredPlan": null + }, + { + "Key": "compact-view", + "Label": "Compact view", + "Description": "Reduce spacing between UI elements for a denser layout", + "Scope": "User", + "AdminLevel": "User", + "RequiredPlan": null + }, + { + "Key": "experimental-ui", + "Label": "Experimental UI", + "Description": "Try out experimental user interface components", + "Scope": "User", + "AdminLevel": "User", + "RequiredPlan": null + } +] diff --git a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts b/application/shared-webapp/ui/featureFlags/labels.ts similarity index 74% rename from application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts rename to application/shared-webapp/ui/featureFlags/labels.ts index a183f690e..b326300d3 100644 --- a/application/account/BackOffice/routes/feature-flags/-components/flagLabels.ts +++ b/application/shared-webapp/ui/featureFlags/labels.ts @@ -5,6 +5,9 @@ interface FeatureFlagLabel { description: string; } +// The English copy here mirrors `FeatureFlags.cs` (Label and Description fields). Backend is the +// source of truth for which flags exist and what they're called; this file exists because Lingui +// needs the strings present at extraction time so translators can localize them. function getKnownFeatureFlagLabels(): Record { return { "google-oauth": { @@ -43,12 +46,19 @@ function formatFeatureFlagKey(flagKey: string): string { return formatted.charAt(0).toUpperCase() + formatted.slice(1); } +export function getFeatureFlagLabel(flagKey: string): FeatureFlagLabel { + const known = getKnownFeatureFlagLabels()[flagKey]; + if (known) return known; + const name = formatFeatureFlagKey(flagKey); + return { name, description: name }; +} + export function getFeatureFlagName(flagKey: string): string { - return getKnownFeatureFlagLabels()[flagKey]?.name ?? formatFeatureFlagKey(flagKey); + return getFeatureFlagLabel(flagKey).name; } export function getFeatureFlagDescription(flagKey: string): string { - return getKnownFeatureFlagLabels()[flagKey]?.description ?? ""; + return getFeatureFlagLabel(flagKey).description; } export function getFeatureFlagSourceLabel(source: string): string { diff --git a/application/shared-webapp/ui/translations/locale/da-DK.po b/application/shared-webapp/ui/translations/locale/da-DK.po index b8096296a..3c2d233b8 100644 --- a/application/shared-webapp/ui/translations/locale/da-DK.po +++ b/application/shared-webapp/ui/translations/locale/da-DK.po @@ -20,6 +20,15 @@ msgstr "{0, plural, one {for # dag siden} other {for # dage siden}}" msgid "{diffDays, plural, one {In # day} other {In # days}}" msgstr "{diffDays, plural, one {Om # dag} other {Om # dage}}" +msgid "A/B rollout" +msgstr "A/B-udrulning" + +msgid "Allow users to authenticate using enterprise identity providers" +msgstr "Tillad brugere at logge ind via virksomhedsidentitetsudbydere" + +msgid "Beta features" +msgstr "Betafunktioner" + msgid "Breadcrumb" msgstr "Brødkrumme" @@ -38,21 +47,42 @@ msgstr "Luk menu" msgid "Close sidebar" msgstr "Luk sidemenu" +msgid "Compact view" +msgstr "Kompakt visning" + msgid "Create <0>{search}" msgstr "Opret <0>{search}" +msgid "Custom branding" +msgstr "Tilpasset branding" + +msgid "Customize the login page with your organization's logo and colors" +msgstr "Tilpas login-siden med din organisations logo og farver" + msgid "Decrease" msgstr "Formindsk" +msgid "Default" +msgstr "Standard" + msgid "Drop files here, or click to browse" msgstr "Slip filer her, eller klik for at vælge" +msgid "Early access to experimental features before general availability" +msgstr "Tidlig adgang til eksperimentelle funktioner før generel tilgængelighed" + +msgid "Experimental UI" +msgstr "Eksperimentel UI" + msgid "Go to next page" msgstr "Gå til næste side" msgid "Go to previous page" msgstr "Gå til forrige side" +msgid "Google OAuth" +msgstr "Google OAuth" + msgid "Increase" msgstr "Forøg" @@ -65,6 +95,9 @@ msgstr "Indlæser" msgid "Main content" msgstr "Hovedindhold" +msgid "Manual override" +msgstr "Manuel tilsidesættelse" + msgid "Mobile navigation menu" msgstr "Mobilnavigationsmenu" @@ -86,9 +119,15 @@ msgstr "Åbn navigationsmenu" msgid "Pagination" msgstr "Paginering" +msgid "Plan" +msgstr "Plan" + msgid "Previous" msgstr "Forrige" +msgid "Reduce spacing between UI elements for a denser layout" +msgstr "Reducér afstanden mellem UI-elementer for et tættere layout" + msgid "Resize sidebar" msgstr "Ændr bredde på sidepanel" @@ -101,12 +140,24 @@ msgstr "Vælg tidszone" msgid "Show more" msgstr "Vis flere" +msgid "Sign in with Google using OpenID Connect" +msgstr "Log ind med Google via OpenID Connect" + +msgid "Single sign-on" +msgstr "Single sign-on" + msgid "Skip to main content" msgstr "Spring til hovedindhold" msgid "Stay" msgstr "Bliv" +msgid "Stripe-powered subscription billing and plan management" +msgstr "Stripe-drevet abonnementsfakturering og abonnementsstyring" + +msgid "Subscriptions" +msgstr "Abonnementer" + msgid "Today" msgstr "I dag" @@ -116,6 +167,9 @@ msgstr "Skift sidepanel" msgid "Tomorrow" msgstr "I morgen" +msgid "Try out experimental user interface components" +msgstr "Afprøv eksperimentelle brugergrænsefladekomponenter" + msgid "Unsaved changes" msgstr "Ugemte ændringer" diff --git a/application/shared-webapp/ui/translations/locale/en-US.po b/application/shared-webapp/ui/translations/locale/en-US.po index d4ac703da..06ae577fb 100644 --- a/application/shared-webapp/ui/translations/locale/en-US.po +++ b/application/shared-webapp/ui/translations/locale/en-US.po @@ -20,6 +20,15 @@ msgstr "{0, plural, one {# day ago} other {# days ago}}" msgid "{diffDays, plural, one {In # day} other {In # days}}" msgstr "{diffDays, plural, one {In # day} other {In # days}}" +msgid "A/B rollout" +msgstr "A/B rollout" + +msgid "Allow users to authenticate using enterprise identity providers" +msgstr "Allow users to authenticate using enterprise identity providers" + +msgid "Beta features" +msgstr "Beta features" + msgid "Breadcrumb" msgstr "Breadcrumb" @@ -38,21 +47,42 @@ msgstr "Close menu" msgid "Close sidebar" msgstr "Close sidebar" +msgid "Compact view" +msgstr "Compact view" + msgid "Create <0>{search}" msgstr "Create <0>{search}" +msgid "Custom branding" +msgstr "Custom branding" + +msgid "Customize the login page with your organization's logo and colors" +msgstr "Customize the login page with your organization's logo and colors" + msgid "Decrease" msgstr "Decrease" +msgid "Default" +msgstr "Default" + msgid "Drop files here, or click to browse" msgstr "Drop files here, or click to browse" +msgid "Early access to experimental features before general availability" +msgstr "Early access to experimental features before general availability" + +msgid "Experimental UI" +msgstr "Experimental UI" + msgid "Go to next page" msgstr "Go to next page" msgid "Go to previous page" msgstr "Go to previous page" +msgid "Google OAuth" +msgstr "Google OAuth" + msgid "Increase" msgstr "Increase" @@ -65,6 +95,9 @@ msgstr "Loading" msgid "Main content" msgstr "Main content" +msgid "Manual override" +msgstr "Manual override" + msgid "Mobile navigation menu" msgstr "Mobile navigation menu" @@ -86,9 +119,15 @@ msgstr "Open navigation menu" msgid "Pagination" msgstr "Pagination" +msgid "Plan" +msgstr "Plan" + msgid "Previous" msgstr "Previous" +msgid "Reduce spacing between UI elements for a denser layout" +msgstr "Reduce spacing between UI elements for a denser layout" + msgid "Resize sidebar" msgstr "Resize sidebar" @@ -101,12 +140,24 @@ msgstr "Select time zone" msgid "Show more" msgstr "Show more" +msgid "Sign in with Google using OpenID Connect" +msgstr "Sign in with Google using OpenID Connect" + +msgid "Single sign-on" +msgstr "Single sign-on" + msgid "Skip to main content" msgstr "Skip to main content" msgid "Stay" msgstr "Stay" +msgid "Stripe-powered subscription billing and plan management" +msgstr "Stripe-powered subscription billing and plan management" + +msgid "Subscriptions" +msgstr "Subscriptions" + msgid "Today" msgstr "Today" @@ -116,6 +167,9 @@ msgstr "Toggle sidebar" msgid "Tomorrow" msgstr "Tomorrow" +msgid "Try out experimental user interface components" +msgstr "Try out experimental user interface components" + msgid "Unsaved changes" msgstr "Unsaved changes" From df28a53b916622b996e39db0006f6bb97d034437 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 14 May 2026 00:56:18 +0200 Subject: [PATCH 067/155] Stabilize feature flag and back-office e2e tests against fresh databases --- .../tests/e2e/back-office-flows.spec.ts | 5 ++- .../tests/e2e/feature-flag-flows.spec.ts | 39 ++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts index c17cfd0e2..ceddf3f9b 100644 --- a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts @@ -15,7 +15,10 @@ test.describe("@smoke", () => { * the accounts route, and the tenant detail route in one journey so any regression to the back-office * shell trips this test on every deployment. */ - test("should log in to back-office, render dashboard, and load tenant detail", async ({ browser }) => { + test("should log in to back-office, render dashboard, and load tenant detail", async ({ ownerPage, browser }) => { + // ownerPage fixture provisions a tenant for this worker so the back-office Accounts list is non-empty + // even when this test runs before signup-driven tests on a fresh database. + createTestContext(ownerPage); const backOfficeContext = await browser.newContext({ baseURL: BACK_OFFICE_BASE_URL, ignoreHTTPSErrors: true }); const page = await backOfficeContext.newPage(); createTestContext(page); diff --git a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts index 9048b3fb4..00e7bbdde 100644 --- a/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/feature-flag-flows.spec.ts @@ -281,7 +281,13 @@ test.describe("@comprehensive", () => { * - Tenants tab: pagination renders when totalPages > 1 and Next advances tenantsPageOffset * - Users tab (compact-view flag): role chip filters down to usersRoles=["Owner"] */ - test("should filter and paginate tenants and users on the feature flag detail page", async ({ browser }) => { + test("should filter and paginate tenants and users on the feature flag detail page", async ({ + ownerPage, + browser + }) => { + // ownerPage fixture provisions a tenant for this worker so the Accounts table is non-empty in + // Enabled state even when this test runs before signup-driven tests on a fresh database. + createTestContext(ownerPage); const backOfficeContext = await browser.newContext({ baseURL: BACK_OFFICE_BASE_URL, ignoreHTTPSErrors: true }); const page = await backOfficeContext.newPage(); createTestContext(page); @@ -332,7 +338,9 @@ test.describe("@comprehensive", () => { await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/feature-flags/beta-features?tenantsState=Disabled`); await expect(disabledChip).toHaveAttribute("aria-pressed", "true"); await expect(enabledChip).toHaveAttribute("aria-pressed", "false"); - await expect(accountsTable).toBeVisible(); + // Note: with rollout=100 and no explicit Disabled overrides, the Disabled filter yields zero + // tenants and the UI shows the Empty state instead of the Accounts table. The chip behavior + // is fully verified above; the table rendering is exercised by the Enabled step. } )(); @@ -450,17 +458,22 @@ test.describe("@comprehensive", () => { // === USERS: ROLE FILTER === - await step("Navigate to compact-view user-scoped flag & verify default Enabled chip and Users table render")( - async () => { - await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/compact-view`); + await step( + "Navigate to compact-view user-scoped flag & verify default Enabled chip is pressed, then click All to render Users table" + )(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/feature-flags/compact-view`); - await expect(page.getByRole("heading", { name: "User status" })).toBeVisible(); - await expect(page.getByRole("table", { name: "Users" })).toBeVisible(); - await expect( - page.getByRole("group", { name: "State" }).getByRole("button", { name: "Enabled" }) - ).toHaveAttribute("aria-pressed", "true"); - } - )(); + await expect(page.getByRole("heading", { name: "User status" })).toBeVisible(); + await expect(page.getByRole("group", { name: "State" }).getByRole("button", { name: "Enabled" })).toHaveAttribute( + "aria-pressed", + "true" + ); + + // compact-view is ConfigurableByUser (not A/B-eligible), so no user is Enabled by default. + // Click All to ensure the Users table renders the worker tenant's users for the role chip test below. + await page.getByRole("group", { name: "State" }).getByRole("button", { name: "All" }).click(); + await expect(page.getByRole("table", { name: "Users" })).toBeVisible(); + })(); const ownerChip = page.getByRole("group", { name: "Role" }).getByRole("button", { name: "Owner" }); @@ -471,7 +484,7 @@ test.describe("@comprehensive", () => { await expect(ownerChip).toHaveAttribute("aria-pressed", "true"); })(); - // === CLEANUP: reset beta-features rollout so the rest of the suite sees the pre-test state === + // === CLEANUP: reset rollouts so the rest of the suite sees the pre-test state === await step("Reset beta-features rollout to 0 via back-office API & verify success")(async () => { const response = await page.request.put( From afed5e8f79307dea84de5d0dd936a75eefc1ad42 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 14 May 2026 01:07:36 +0200 Subject: [PATCH 068/155] Generate frontend feature flag labels from backend manifest --- .gitignore | 1 + application/account/Api/Account.Api.csproj | 17 ++ .../Api/FeatureFlagsManifestEmitter.cs | 71 +++++++ application/account/Api/Program.cs | 7 + .../tests/e2e/feature-flag-flows.spec.ts | 47 ++--- .../FeatureFlags/FeatureFlagDefinition.cs | 1 + .../SharedKernel/FeatureFlags/FeatureFlags.cs | 6 +- .../infrastructure/featureFlags/registry.ts | 85 -------- .../featureFlags/useFeatureFlag.ts | 3 +- .../featureFlags/featureFlags.generated.json | 58 ------ .../shared-webapp/ui/featureFlags/labels.ts | 87 +------- .../scripts/generateFeatureFlagArtifacts.mjs | 188 ++++++++++++++++++ 12 files changed, 323 insertions(+), 248 deletions(-) create mode 100644 application/account/Api/FeatureFlagsManifestEmitter.cs delete mode 100644 application/shared-webapp/infrastructure/featureFlags/registry.ts delete mode 100644 application/shared-webapp/ui/featureFlags/featureFlags.generated.json create mode 100644 application/shared-webapp/ui/scripts/generateFeatureFlagArtifacts.mjs diff --git a/.gitignore b/.gitignore index cf072a5ae..be01f1cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -392,6 +392,7 @@ FodyWeavers.xsd **/package.g.props **/*.generated.d.ts **/*.generated.ts +**/*.generated.json **/Api/swagger.json **/lib/api/*.Api.json **/lib/api/*.Api_*.json diff --git a/application/account/Api/Account.Api.csproj b/application/account/Api/Account.Api.csproj index cc9eb042e..8155ba469 100644 --- a/application/account/Api/Account.Api.csproj +++ b/application/account/Api/Account.Api.csproj @@ -41,6 +41,23 @@ + + + + <_FeatureFlagsManifestPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\featureFlags.generated.json')) + + + + + + + - - - <_FeatureFlagsManifestPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\featureFlags.generated.json')) - + + <_FeatureFlagsManifestPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\featureFlags.generated.json')) + <_FeatureFlagsLabelsPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\labels.generated.ts')) + <_FeatureFlagsRegistryPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\registry.generated.ts')) + <_FeatureFlagsSourcePath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-kernel\SharedKernel\FeatureFlags\FeatureFlags.cs')) + <_FeatureFlagsCodegenScriptPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\scripts\generateFeatureFlagArtifacts.mjs')) + + - +