From 9f4f7cb7425662d947837d1e4090b0e560566a83 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 21:37:21 +0000 Subject: [PATCH 1/2] feat: add feature flag module with permissions-like architecture Add a complete FeatureFlags module that follows the permissions pattern: modules declare features via IModuleFeatures marker classes, the source generator auto-discovers them, and a runtime service resolves flag state with user/role override support. ## Framework (SimpleModule.Core) - IModuleFeatures marker interface for compile-time feature discovery - IFeatureFlagService for cross-module runtime checks - IFeatureFlagRegistry singleton with all known flag definitions - FeatureFlagRegistryBuilder merging discovered + configured definitions - EndpointFeatureFlagExtensions with .RequireFeature() endpoint filter - ConfigureFeatureFlags() added to IModule interface ## Source Generator - Feature class discovery mirroring permission class pattern - Diagnostics SM0045-SM0048 (sealed class, naming, duplicates, fields) - Auto-registration of feature classes and definitions in AddModules() ## Module Implementation - Contracts: IFeatureFlagContracts, DTOs, events, OverrideType enum - Entities using Entity base class (auto timestamps, concurrency) - FeatureFlagService with cached resolution (user > role > global > default) - FeatureFlagSyncService for startup DB sync with table auto-creation - FeatureFlagMiddleware injecting flags into Inertia shared data - Full API endpoints (CRUD flags, overrides, check) - Admin UI (React) with toggle switches and override management - useFeatureFlag() React hook for frontend consumption - Permissions: FeatureFlags.View, FeatureFlags.Manage ## Tests - 14 unit + integration tests (service logic, endpoint auth, CRUD) - Playwright e2e smoke + flow tests - Test infrastructure integration (WebApplicationFactory) --- SimpleModule.slnx | 5 + .../EndpointFeatureFlagExtensions.cs | 49 +++ .../FeatureFlags/FeatureFlagBuilder.cs | 14 + .../FeatureFlags/FeatureFlagDefinition.cs | 8 + .../FeatureFlags/FeatureFlagRegistry.cs | 28 ++ .../FeatureFlagRegistryBuilder.cs | 57 +++ .../FeatureFlags/IFeatureFlagBuilder.cs | 6 + .../FeatureFlags/IFeatureFlagRegistry.cs | 9 + .../FeatureFlags/IFeatureFlagService.cs | 19 + .../FeatureFlags/IModuleFeatures.cs | 10 + framework/SimpleModule.Core/IModule.cs | 2 + .../AnalyzerReleases.Unshipped.md | 4 + .../Discovery/DiscoveryData.cs | 53 +++ .../Discovery/SymbolDiscovery.cs | 113 +++++- .../Emitters/DiagnosticEmitter.cs | 108 +++++- .../Emitters/ModuleExtensionsEmitter.cs | 40 ++ .../Events/FeatureFlagOverrideChangedEvent.cs | 16 + .../Events/FeatureFlagToggledEvent.cs | 9 + .../FeatureFlag.cs | 11 + .../FeatureFlagOverride.cs | 10 + .../FeatureFlagsConstants.cs | 7 + .../IFeatureFlagContracts.cs | 22 ++ .../OverrideType.cs | 7 + .../SetOverrideRequest.cs | 8 + ...SimpleModule.FeatureFlags.Contracts.csproj | 9 + .../UpdateFeatureFlagRequest.cs | 6 + .../Endpoints/FeatureFlags/CheckEndpoint.cs | 24 ++ .../FeatureFlags/DeleteOverrideEndpoint.cs | 22 ++ .../Endpoints/FeatureFlags/GetAllEndpoint.cs | 22 ++ .../FeatureFlags/GetOverridesEndpoint.cs | 22 ++ .../FeatureFlags/SetOverrideEndpoint.cs | 40 ++ .../Endpoints/FeatureFlags/UpdateEndpoint.cs | 36 ++ .../Entities/FeatureFlagConfiguration.cs | 16 + .../Entities/FeatureFlagEntity.cs | 10 + .../FeatureFlagOverrideConfiguration.cs | 17 + .../Entities/FeatureFlagOverrideEntity.cs | 12 + .../FeatureFlagService.cs | 345 ++++++++++++++++++ .../FeatureFlagSyncService.cs | 89 +++++ .../FeatureFlagsDbContext.cs | 23 ++ .../FeatureFlagsModule.cs | 56 +++ .../FeatureFlagsPermissions.cs | 9 + .../Middleware/FeatureFlagMiddleware.cs | 28 ++ .../SimpleModule.FeatureFlags/Pages/index.ts | 3 + .../SimpleModule.FeatureFlags.csproj | 11 + .../Views/Manage.tsx | 243 ++++++++++++ .../Views/ManageEndpoint.cs | 26 ++ .../SimpleModule.FeatureFlags/constants.ts | 3 + .../SimpleModule.FeatureFlags/package.json | 14 + .../src/SimpleModule.FeatureFlags/types.ts | 28 ++ .../SimpleModule.FeatureFlags/vite.config.ts | 3 + .../GlobalUsings.cs | 1 + .../Integration/FeatureFlagEndpointTests.cs | 125 +++++++ .../SimpleModule.FeatureFlags.Tests.csproj | 23 ++ .../Unit/FeatureFlagServiceTests.cs | 219 +++++++++++ package-lock.json | 12 + .../SimpleModule.UI/hooks/use-feature-flag.ts | 6 + packages/SimpleModule.UI/package.json | 3 +- .../SimpleModule.Host.csproj | 1 + .../TopologicalSortTests.cs | 10 + .../SimpleModuleWebApplicationFactory.cs | 48 ++- .../SimpleModule.Tests.Shared.csproj | 1 + tests/e2e/pages/feature-flags/manage.page.ts | 49 +++ .../tests/flows/feature-flags-crud.spec.ts | 115 ++++++ tests/e2e/tests/smoke/feature-flags.spec.ts | 16 + 64 files changed, 2348 insertions(+), 13 deletions(-) create mode 100644 framework/SimpleModule.Core/FeatureFlags/EndpointFeatureFlagExtensions.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/FeatureFlagBuilder.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/FeatureFlagDefinition.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistry.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistryBuilder.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/IFeatureFlagBuilder.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/IFeatureFlagRegistry.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/IFeatureFlagService.cs create mode 100644 framework/SimpleModule.Core/FeatureFlags/IModuleFeatures.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlag.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagOverride.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagsConstants.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/IFeatureFlagContracts.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SetOverrideRequest.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SimpleModule.FeatureFlags.Contracts.csproj create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/UpdateFeatureFlagRequest.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/CheckEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/DeleteOverrideEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetAllEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetOverridesEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagConfiguration.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagEntity.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideConfiguration.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideEntity.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsDbContext.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsPermissions.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Pages/index.ts create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.csproj create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/Manage.tsx create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/ManageEndpoint.cs create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/constants.ts create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/package.json create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/types.ts create mode 100644 modules/FeatureFlags/src/SimpleModule.FeatureFlags/vite.config.ts create mode 100644 modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/GlobalUsings.cs create mode 100644 modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Integration/FeatureFlagEndpointTests.cs create mode 100644 modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/SimpleModule.FeatureFlags.Tests.csproj create mode 100644 modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs create mode 100644 packages/SimpleModule.UI/hooks/use-feature-flag.ts create mode 100644 tests/e2e/pages/feature-flags/manage.page.ts create mode 100644 tests/e2e/tests/flows/feature-flags-crud.spec.ts create mode 100644 tests/e2e/tests/smoke/feature-flags.spec.ts diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 717a6b8b..73bf2092 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -69,6 +69,11 @@ + + + + + diff --git a/framework/SimpleModule.Core/FeatureFlags/EndpointFeatureFlagExtensions.cs b/framework/SimpleModule.Core/FeatureFlags/EndpointFeatureFlagExtensions.cs new file mode 100644 index 00000000..0b604a48 --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/EndpointFeatureFlagExtensions.cs @@ -0,0 +1,49 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace SimpleModule.Core.FeatureFlags; + +public static class EndpointFeatureFlagExtensions +{ + public static TBuilder RequireFeature(this TBuilder builder, string featureName) + where TBuilder : IEndpointConventionBuilder + { + builder.AddEndpointFilter( + async (context, next) => + { + var featureFlagService = + context.HttpContext.RequestServices.GetService(); + + // If the service is not registered, allow the request (feature enabled by default) + if (featureFlagService is null) + { + return await next(context); + } + + var userId = context.HttpContext.User.FindFirstValue( + ClaimTypes.NameIdentifier + ); + var roles = context + .HttpContext.User.FindAll(ClaimTypes.Role) + .Select(c => c.Value); + + var isEnabled = await featureFlagService.IsEnabledAsync( + featureName, + userId, + roles + ); + + if (!isEnabled) + { + return Results.NotFound(); + } + + return await next(context); + } + ); + + return builder; + } +} diff --git a/framework/SimpleModule.Core/FeatureFlags/FeatureFlagBuilder.cs b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagBuilder.cs new file mode 100644 index 00000000..325cc621 --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagBuilder.cs @@ -0,0 +1,14 @@ +namespace SimpleModule.Core.FeatureFlags; + +public sealed class FeatureFlagBuilder : IFeatureFlagBuilder +{ + private readonly List _definitions = []; + + public IFeatureFlagBuilder Add(FeatureFlagDefinition definition) + { + _definitions.Add(definition); + return this; + } + + public List ToList() => [.. _definitions]; +} diff --git a/framework/SimpleModule.Core/FeatureFlags/FeatureFlagDefinition.cs b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagDefinition.cs new file mode 100644 index 00000000..4e6c1acb --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagDefinition.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.Core.FeatureFlags; + +public class FeatureFlagDefinition +{ + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool DefaultEnabled { get; set; } +} diff --git a/framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistry.cs b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistry.cs new file mode 100644 index 00000000..76c53020 --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistry.cs @@ -0,0 +1,28 @@ +namespace SimpleModule.Core.FeatureFlags; + +public sealed class FeatureFlagRegistry : IFeatureFlagRegistry +{ + private readonly Dictionary _definitions; + + public FeatureFlagRegistry( + HashSet allNames, + List definitions + ) + { + // allNames parameter kept for API compatibility with generated code. + // Build() already ensures definitions contains entries for all names. + _ = allNames; + _definitions = definitions.ToDictionary(d => d.Name); + } + + public FeatureFlagDefinition? GetDefinition(string name) => + _definitions.GetValueOrDefault(name); + + public IReadOnlyList GetAllDefinitions() => + _definitions.Values.ToList(); + + public bool IsKnownFeature(string name) => _definitions.ContainsKey(name); + + public IReadOnlySet GetAllFeatureNames() => + _definitions.Keys.ToHashSet(StringComparer.Ordinal); +} diff --git a/framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistryBuilder.cs b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistryBuilder.cs new file mode 100644 index 00000000..957dcd8c --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistryBuilder.cs @@ -0,0 +1,57 @@ +using System.Reflection; + +namespace SimpleModule.Core.FeatureFlags; + +public sealed class FeatureFlagRegistryBuilder +{ + private readonly HashSet _allNames = []; + private readonly Dictionary _definitions = new(); + + public void AddFeatures() + where T : class + { + var fields = typeof(T) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + foreach (var field in fields) + { + var value = (string)field.GetRawConstantValue()!; + _allNames.Add(value); + } + } + + public void AddDefinition(FeatureFlagDefinition definition) + { + _allNames.Add(definition.Name); + _definitions[definition.Name] = definition; + } + + public void AddDefinitions(List definitions) + { + foreach (var definition in definitions) + { + AddDefinition(definition); + } + } + + public FeatureFlagRegistry Build() + { + // For names discovered via IModuleFeatures but without a definition, + // create a default definition (disabled by default). + var allDefinitions = new List(); + foreach (var name in _allNames) + { + if (_definitions.TryGetValue(name, out var def)) + { + allDefinitions.Add(def); + } + else + { + allDefinitions.Add(new FeatureFlagDefinition { Name = name }); + } + } + + return new FeatureFlagRegistry(_allNames, allDefinitions); + } +} diff --git a/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagBuilder.cs b/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagBuilder.cs new file mode 100644 index 00000000..6b962edf --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagBuilder.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Core.FeatureFlags; + +public interface IFeatureFlagBuilder +{ + IFeatureFlagBuilder Add(FeatureFlagDefinition definition); +} diff --git a/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagRegistry.cs b/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagRegistry.cs new file mode 100644 index 00000000..4a7f1eab --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagRegistry.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Core.FeatureFlags; + +public interface IFeatureFlagRegistry +{ + FeatureFlagDefinition? GetDefinition(string name); + IReadOnlyList GetAllDefinitions(); + bool IsKnownFeature(string name); + IReadOnlySet GetAllFeatureNames(); +} diff --git a/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagService.cs b/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagService.cs new file mode 100644 index 00000000..b482e2cf --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/IFeatureFlagService.cs @@ -0,0 +1,19 @@ +namespace SimpleModule.Core.FeatureFlags; + +/// +/// Minimal runtime interface for checking feature flag state. +/// Lives in Core so any module can depend on it without referencing FeatureFlags.Contracts. +/// +public interface IFeatureFlagService +{ + Task IsEnabledAsync( + string flagName, + string? userId = null, + IEnumerable? roles = null + ); + + Task> GetAllEnabledAsync( + string? userId = null, + IEnumerable? roles = null + ); +} diff --git a/framework/SimpleModule.Core/FeatureFlags/IModuleFeatures.cs b/framework/SimpleModule.Core/FeatureFlags/IModuleFeatures.cs new file mode 100644 index 00000000..421f5e52 --- /dev/null +++ b/framework/SimpleModule.Core/FeatureFlags/IModuleFeatures.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.FeatureFlags; + +/// +/// Marker interface for feature flag classes. Implementations are auto-discovered +/// by the source generator and registered with the feature flag registry. +/// Feature classes must be sealed and contain only public const string fields. +/// +#pragma warning disable CA1040 // Avoid empty interfaces +public interface IModuleFeatures; +#pragma warning restore CA1040 diff --git a/framework/SimpleModule.Core/IModule.cs b/framework/SimpleModule.Core/IModule.cs index 00e8e3a1..451ad969 100644 --- a/framework/SimpleModule.Core/IModule.cs +++ b/framework/SimpleModule.Core/IModule.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core.Authorization; +using SimpleModule.Core.FeatureFlags; using SimpleModule.Core.Menu; using SimpleModule.Core.Settings; @@ -16,6 +17,7 @@ virtual void ConfigureMiddleware(IApplicationBuilder app) { } virtual void ConfigureMenu(IMenuBuilder menus) { } virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } virtual void ConfigureSettings(ISettingsBuilder settings) { } + virtual void ConfigureFeatureFlags(IFeatureFlagBuilder builder) { } /// /// Called once during application startup after all services are registered. diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md index 535d0cc3..bc74127c 100644 --- a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -31,3 +31,7 @@ SM0041 | SimpleModule.Generator | Warning | View page name does not match module SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods SM0044 | SimpleModule.Generator | Warning | Multiple IModuleOptions for same module +SM0045 | SimpleModule.Generator | Error | Feature class is not sealed +SM0046 | SimpleModule.Generator | Warning | Feature field naming violation +SM0047 | SimpleModule.Generator | Error | Duplicate feature name +SM0048 | SimpleModule.Generator | Error | Feature field is not a const string diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index da542102..8d6a0fbe 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -27,6 +27,7 @@ internal readonly record struct DiscoveryData( ImmutableArray ContractInterfaces, ImmutableArray ContractImplementations, ImmutableArray PermissionClasses, + ImmutableArray FeatureClasses, ImmutableArray Interceptors, ImmutableArray VogenValueObjects, ImmutableArray ModuleOptions, @@ -43,6 +44,7 @@ string HostAssemblyName ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, @@ -60,6 +62,7 @@ public bool Equals(DiscoveryData other) && ContractInterfaces.SequenceEqual(other.ContractInterfaces) && ContractImplementations.SequenceEqual(other.ContractImplementations) && PermissionClasses.SequenceEqual(other.PermissionClasses) + && FeatureClasses.SequenceEqual(other.FeatureClasses) && Interceptors.SequenceEqual(other.Interceptors) && VogenValueObjects.SequenceEqual(other.VogenValueObjects) && ModuleOptions.SequenceEqual(other.ModuleOptions) @@ -78,6 +81,7 @@ public override int GetHashCode() hash = HashHelper.HashArray(hash, ContractInterfaces); hash = HashHelper.HashArray(hash, ContractImplementations); hash = HashHelper.HashArray(hash, PermissionClasses); + hash = HashHelper.HashArray(hash, FeatureClasses); hash = HashHelper.HashArray(hash, Interceptors); hash = HashHelper.HashArray(hash, VogenValueObjects); hash = HashHelper.HashArray(hash, ModuleOptions); @@ -95,6 +99,7 @@ internal readonly record struct ModuleInfoRecord( bool HasConfigurePermissions, bool HasConfigureMiddleware, bool HasConfigureSettings, + bool HasConfigureFeatureFlags, bool HasRazorComponents, string RoutePrefix, string ViewPrefix, @@ -112,6 +117,7 @@ public bool Equals(ModuleInfoRecord other) && HasConfigureMiddleware == other.HasConfigureMiddleware && HasConfigurePermissions == other.HasConfigurePermissions && HasConfigureSettings == other.HasConfigureSettings + && HasConfigureFeatureFlags == other.HasConfigureFeatureFlags && HasRazorComponents == other.HasRazorComponents && RoutePrefix == other.RoutePrefix && ViewPrefix == other.ViewPrefix @@ -130,6 +136,7 @@ public override int GetHashCode() hash = HashHelper.Combine(hash, HasConfigureMiddleware.GetHashCode()); hash = HashHelper.Combine(hash, HasConfigurePermissions.GetHashCode()); hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode()); + hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode()); hash = HashHelper.Combine(hash, HasRazorComponents.GetHashCode()); hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode()); hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode()); @@ -295,6 +302,36 @@ internal readonly record struct PermissionFieldRecord( bool IsConstString ); +internal readonly record struct FeatureClassRecord( + string FullyQualifiedName, + string ModuleName, + bool IsSealed, + ImmutableArray Fields +) +{ + public bool Equals(FeatureClassRecord other) => + FullyQualifiedName == other.FullyQualifiedName + && ModuleName == other.ModuleName + && IsSealed == other.IsSealed + && Fields.SequenceEqual(other.Fields); + + public override int GetHashCode() + { + var hash = 17; + hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode()); + hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, IsSealed.GetHashCode()); + hash = HashHelper.HashArray(hash, Fields); + return hash; + } +} + +internal readonly record struct FeatureFieldRecord( + string FieldName, + string Value, + bool IsConstString +); + internal readonly record struct InterceptorInfoRecord( string FullyQualifiedName, string ModuleName, @@ -359,6 +396,7 @@ internal sealed class ModuleInfo public bool HasConfigurePermissions { get; set; } public bool HasConfigureMiddleware { get; set; } public bool HasConfigureSettings { get; set; } + public bool HasConfigureFeatureFlags { get; set; } public bool HasRazorComponents { get; set; } public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; @@ -450,6 +488,21 @@ internal sealed class PermissionFieldInfo public bool IsConstString { get; set; } } +internal sealed class FeatureClassInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; + public bool IsSealed { get; set; } + public List Fields { get; set; } = new(); +} + +internal sealed class FeatureFieldInfo +{ + public string FieldName { get; set; } = ""; + public string Value { get; set; } = ""; + public bool IsConstString { get; set; } +} + internal sealed class InterceptorInfo { public string FullyQualifiedName { get; set; } = ""; diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 29f8e2f0..0f3cc778 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -345,7 +345,43 @@ is not IAssemblySymbol assemblySymbol } } - // Step 3d: Find ISaveChangesInterceptor implementors in module assemblies + // Step 3d: Find IModuleFeatures implementors in module and contracts assemblies + var featureClasses = new List(); + var moduleFeaturesSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.FeatureFlags.IModuleFeatures" + ); + if (moduleFeaturesSymbol is not null) + { + foreach (var module in modules) + { + if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) + continue; + + var moduleAssembly = typeSymbol.ContainingAssembly; + FindFeatureClasses( + moduleAssembly.GlobalNamespace, + moduleFeaturesSymbol, + module.ModuleName, + featureClasses + ); + } + + // Also scan contracts assemblies for feature classes + foreach (var kvp in contractsAssemblySymbols) + { + if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) + { + FindFeatureClasses( + kvp.Value.GlobalNamespace, + moduleFeaturesSymbol, + moduleName, + featureClasses + ); + } + } + } + + // Step 3e: Find ISaveChangesInterceptor implementors in module assemblies var interceptors = new List(); var saveChangesInterceptorSymbol = compilation.GetTypeByMetadataName( "Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor" @@ -496,6 +532,7 @@ is not IAssemblySymbol assemblySymbol m.HasConfigurePermissions, m.HasConfigureMiddleware, m.HasConfigureSettings, + m.HasConfigureFeatureFlags, m.HasRazorComponents, m.RoutePrefix, m.ViewPrefix, @@ -563,6 +600,19 @@ is not IAssemblySymbol assemblySymbol .ToImmutableArray() )) .ToImmutableArray(), + featureClasses + .Select(f => new FeatureClassRecord( + f.FullyQualifiedName, + f.ModuleName, + f.IsSealed, + f.Fields.Select(ff => new FeatureFieldRecord( + ff.FieldName, + ff.Value, + ff.IsConstString + )) + .ToImmutableArray() + )) + .ToImmutableArray(), interceptors .Select(i => new InterceptorInfoRecord( i.FullyQualifiedName, @@ -651,6 +701,10 @@ List modules typeSymbol, "ConfigureSettings" ), + HasConfigureFeatureFlags = DeclaresMethod( + typeSymbol, + "ConfigureFeatureFlags" + ), RoutePrefix = routePrefix, ViewPrefix = viewPrefix, } @@ -1262,6 +1316,63 @@ m is IFieldSymbol field } } + private static void FindFeatureClasses( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol moduleFeaturesSymbol, + string moduleName, + List results + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNs) + { + FindFeatureClasses(childNs, moduleFeaturesSymbol, moduleName, results); + } + else if ( + member is INamedTypeSymbol typeSymbol + && typeSymbol.TypeKind == TypeKind.Class + && ImplementsInterface(typeSymbol, moduleFeaturesSymbol) + ) + { + var info = new FeatureClassInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + IsSealed = typeSymbol.IsSealed, + }; + + // Collect public const string fields + foreach (var m in typeSymbol.GetMembers()) + { + if ( + m is IFieldSymbol field + && field.DeclaredAccessibility == Accessibility.Public + ) + { + info.Fields.Add( + new FeatureFieldInfo + { + FieldName = field.Name, + Value = + field.HasConstantValue && field.ConstantValue is string s + ? s + : "", + IsConstString = + field.IsConst + && field.Type.SpecialType == SpecialType.System_String, + } + ); + } + } + + results.Add(info); + } + } + } + private static void FindModuleOptionsClasses( INamespaceSymbol namespaceSymbol, INamedTypeSymbol moduleOptionsSymbol, diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 53dd2d83..246c4224 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -269,6 +269,42 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); + private static readonly DiagnosticDescriptor FeatureClassNotSealed = new( + id: "SM0045", + title: "Feature class is not sealed", + messageFormat: "'{0}' implements IModuleFeatures but is not sealed", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor FeatureFieldNamingViolation = new( + id: "SM0046", + title: "Feature field naming violation", + messageFormat: "Feature '{0}' in '{1}' does not follow the 'ModuleName.FeatureName' pattern", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor DuplicateFeatureName = new( + id: "SM0047", + title: "Duplicate feature name", + messageFormat: "Feature name '{0}' is defined in both '{1}' and '{2}'", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor FeatureFieldNotConstString = new( + id: "SM0048", + title: "Feature field is not a const string", + messageFormat: "Field '{0}' in feature class '{1}' must be a public const string", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -324,6 +360,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) && !module.HasConfigurePermissions && !module.HasConfigureMiddleware && !module.HasConfigureSettings + && !module.HasConfigureFeatureFlags && module.Endpoints.Length == 0 && module.Views.Length == 0 && !moduleNamesWithDbContext.Contains(module.ModuleName)) @@ -723,10 +760,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } // SM0035: DTO type in contracts with no public properties - // Exclude permission classes — they only have const string fields, not properties + // Exclude permission and feature classes — they only have const string fields, not properties var permissionClassFqns = new HashSet(); foreach (var perm in data.PermissionClasses) permissionClassFqns.Add(perm.FullyQualifiedName); + foreach (var feat in data.FeatureClasses) + permissionClassFqns.Add(feat.FullyQualifiedName); foreach (var dto in data.DtoTypes) { @@ -878,6 +917,73 @@ public void Emit(SourceProductionContext context, DiscoveryData data) ); } } + + // SM0045/SM0046/SM0047/SM0048: Feature flag diagnostics + var featureValueOwners = new Dictionary(); + + foreach (var feat in data.FeatureClasses) + { + var featCleanName = Strip(feat.FullyQualifiedName); + + // SM0045: Not sealed + if (!feat.IsSealed) + { + context.ReportDiagnostic( + Diagnostic.Create(FeatureClassNotSealed, Location.None, featCleanName) + ); + } + + foreach (var field in feat.Fields) + { + // SM0048: Not a const string + if (!field.IsConstString) + { + context.ReportDiagnostic( + Diagnostic.Create( + FeatureFieldNotConstString, + Location.None, + field.FieldName, + featCleanName + ) + ); + continue; + } + + // SM0046: Naming violation + if (!field.Value.Contains(".")) + { + context.ReportDiagnostic( + Diagnostic.Create( + FeatureFieldNamingViolation, + Location.None, + field.Value, + featCleanName + ) + ); + } + + // SM0047: Duplicate feature name + if (featureValueOwners.TryGetValue(field.Value, out var existingOwner)) + { + if (existingOwner != feat.FullyQualifiedName) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateFeatureName, + Location.None, + field.Value, + Strip(existingOwner), + featCleanName + ) + ); + } + } + else + { + featureValueOwners[field.Value] = feat.FullyQualifiedName; + } + } + } } private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs index 40dfe968..9ee59c5d 100644 --- a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs @@ -140,6 +140,46 @@ public void Emit(SourceProductionContext context, DiscoveryData data) " services.AddSingleton();" ); + sb.AppendLine(); + sb.AppendLine( + " var featureFlagBuilder = new SimpleModule.Core.FeatureFlags.FeatureFlagRegistryBuilder();" + ); + + sb.AppendLine(); + sb.AppendLine(" // Auto-discovered feature classes (IModuleFeatures)"); + foreach (var feature in data.FeatureClasses) + { + if (feature.IsSealed) + { + sb.AppendLine( + $" featureFlagBuilder.AddFeatures<{feature.FullyQualifiedName}>();" + ); + } + } + + // Collect feature flag definitions from modules + if (sortedModules.Any(m => m.HasConfigureFeatureFlags)) + { + sb.AppendLine(); + sb.AppendLine( + " var featureFlagDefinitionBuilder = new SimpleModule.Core.FeatureFlags.FeatureFlagBuilder();" + ); + foreach (var module in sortedModules.Where(m => m.HasConfigureFeatureFlags)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine( + $" ((global::SimpleModule.Core.IModule){fieldName}).ConfigureFeatureFlags(featureFlagDefinitionBuilder);" + ); + } + sb.AppendLine( + " featureFlagBuilder.AddDefinitions(featureFlagDefinitionBuilder.ToList());" + ); + } + + sb.AppendLine( + " services.AddSingleton(featureFlagBuilder.Build());" + ); + if (hasDtoTypes) { sb.AppendLine(); diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs new file mode 100644 index 00000000..72a1fd34 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.FeatureFlags.Contracts.Events; + +public enum OverrideAction +{ + Set, + Delete, +} + +public sealed record FeatureFlagOverrideChangedEvent( + string FlagName, + OverrideAction Action, + OverrideType OverrideType, + string OverrideValue +) : IEvent; diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs new file mode 100644 index 00000000..c3059fde --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs @@ -0,0 +1,9 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.FeatureFlags.Contracts.Events; + +public sealed record FeatureFlagToggledEvent( + string FlagName, + bool IsEnabled, + string UserId +) : IEvent; diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlag.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlag.cs new file mode 100644 index 00000000..87d7abce --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlag.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public class FeatureFlag +{ + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + public bool DefaultEnabled { get; set; } + public bool IsDeprecated { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagOverride.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagOverride.cs new file mode 100644 index 00000000..2aecaf1e --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagOverride.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public class FeatureFlagOverride +{ + public int Id { get; set; } + public string FlagName { get; set; } = string.Empty; + public OverrideType OverrideType { get; set; } + public string OverrideValue { get; set; } = string.Empty; + public bool IsEnabled { get; set; } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagsConstants.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagsConstants.cs new file mode 100644 index 00000000..ce1387f3 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/FeatureFlagsConstants.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public static class FeatureFlagsConstants +{ + public const string ModuleName = "FeatureFlags"; + public const string RoutePrefix = "/api/feature-flags"; +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/IFeatureFlagContracts.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/IFeatureFlagContracts.cs new file mode 100644 index 00000000..69c1f9d1 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/IFeatureFlagContracts.cs @@ -0,0 +1,22 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public interface IFeatureFlagContracts +{ + Task IsEnabledAsync( + string flagName, + string? userId = null, + IEnumerable? roles = null + ); + + Task> GetAllEnabledAsync( + string? userId = null, + IEnumerable? roles = null + ); + + Task> GetAllFlagsAsync(); + Task GetFlagAsync(string flagName); + Task UpdateFlagAsync(string flagName, UpdateFeatureFlagRequest request); + Task> GetOverridesAsync(string flagName); + Task SetOverrideAsync(string flagName, SetOverrideRequest request); + Task DeleteOverrideAsync(int overrideId); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs new file mode 100644 index 00000000..970d4ea2 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public enum OverrideType +{ + User = 0, + Role = 1, +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SetOverrideRequest.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SetOverrideRequest.cs new file mode 100644 index 00000000..076e9a55 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SetOverrideRequest.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public class SetOverrideRequest +{ + public OverrideType OverrideType { get; set; } + public string OverrideValue { get; set; } = string.Empty; + public bool IsEnabled { get; set; } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SimpleModule.FeatureFlags.Contracts.csproj b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SimpleModule.FeatureFlags.Contracts.csproj new file mode 100644 index 00000000..b33451b0 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SimpleModule.FeatureFlags.Contracts.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Library + + + + + diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/UpdateFeatureFlagRequest.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/UpdateFeatureFlagRequest.cs new file mode 100644 index 00000000..2b5e86e0 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/UpdateFeatureFlagRequest.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.FeatureFlags.Contracts; + +public class UpdateFeatureFlagRequest +{ + public bool IsEnabled { get; set; } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/CheckEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/CheckEndpoint.cs new file mode 100644 index 00000000..44264704 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/CheckEndpoint.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags; + +public class CheckEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/check/{name}", + async (string name, IFeatureFlagContracts featureFlags, ClaimsPrincipal user) => + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value); + var isEnabled = await featureFlags.IsEnabledAsync(name, userId, roles); + return TypedResults.Ok(new { name, isEnabled }); + } + ) + .RequireAuthorization(); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/DeleteOverrideEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/DeleteOverrideEndpoint.cs new file mode 100644 index 00000000..b0b552b8 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/DeleteOverrideEndpoint.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags; + +public class DeleteOverrideEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapDelete( + "/overrides/{id:int}", + async (int id, IFeatureFlagContracts featureFlags) => + { + await featureFlags.DeleteOverrideAsync(id); + return TypedResults.NoContent(); + } + ) + .RequirePermission(FeatureFlagsPermissions.Manage); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetAllEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetAllEndpoint.cs new file mode 100644 index 00000000..ae0a082e --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetAllEndpoint.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags; + +public class GetAllEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/", + async (IFeatureFlagContracts featureFlags) => + { + var flags = await featureFlags.GetAllFlagsAsync(); + return TypedResults.Ok(flags); + } + ) + .RequirePermission(FeatureFlagsPermissions.View); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetOverridesEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetOverridesEndpoint.cs new file mode 100644 index 00000000..4c3b2f93 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/GetOverridesEndpoint.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags; + +public class GetOverridesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/{name}/overrides", + async (string name, IFeatureFlagContracts featureFlags) => + { + var overrides = await featureFlags.GetOverridesAsync(name); + return TypedResults.Ok(overrides); + } + ) + .RequirePermission(FeatureFlagsPermissions.View); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs new file mode 100644 index 00000000..1cadf098 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Events; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.FeatureFlags.Contracts.Events; + +namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags; + +public class SetOverrideEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + "/{name}/overrides", + async ( + string name, + SetOverrideRequest request, + IFeatureFlagContracts featureFlags, + IEventBus eventBus + ) => + { + var result = await featureFlags.SetOverrideAsync(name, request); + await eventBus.PublishAsync( + new FeatureFlagOverrideChangedEvent( + name, + OverrideAction.Set, + request.OverrideType, + request.OverrideValue + ) + ); + return TypedResults.Created( + $"{FeatureFlagsConstants.RoutePrefix}/{name}/overrides", + result + ); + } + ) + .RequirePermission(FeatureFlagsPermissions.Manage); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs new file mode 100644 index 00000000..c631c528 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Events; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.FeatureFlags.Contracts.Events; + +namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags; + +public class UpdateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut( + "/{name}", + async ( + string name, + UpdateFeatureFlagRequest request, + IFeatureFlagContracts featureFlags, + IEventBus eventBus, + ClaimsPrincipal user + ) => + { + var flag = await featureFlags.UpdateFlagAsync(name, request); + var userId = + user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; + await eventBus.PublishAsync( + new FeatureFlagToggledEvent(name, request.IsEnabled, userId) + ); + return TypedResults.Ok(flag); + } + ) + .RequirePermission(FeatureFlagsPermissions.Manage); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagConfiguration.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagConfiguration.cs new file mode 100644 index 00000000..689395f4 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SimpleModule.FeatureFlags.Entities; + +public class FeatureFlagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(f => f.Id); + builder.Property(f => f.Id).ValueGeneratedOnAdd(); + builder.Property(f => f.Name).IsRequired().HasMaxLength(256); + builder.HasIndex(f => f.Name).IsUnique(); + builder.Property(f => f.ConcurrencyStamp).HasMaxLength(40).IsConcurrencyToken(); + } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagEntity.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagEntity.cs new file mode 100644 index 00000000..bf74c7be --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagEntity.cs @@ -0,0 +1,10 @@ +using SimpleModule.Core.Entities; + +namespace SimpleModule.FeatureFlags.Entities; + +public class FeatureFlagEntity : Entity +{ + public string Name { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + public bool IsDeprecated { get; set; } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideConfiguration.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideConfiguration.cs new file mode 100644 index 00000000..84c4daed --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SimpleModule.FeatureFlags.Entities; + +public class FeatureFlagOverrideConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).ValueGeneratedOnAdd(); + builder.Property(o => o.FlagName).IsRequired().HasMaxLength(256); + builder.Property(o => o.OverrideValue).IsRequired().HasMaxLength(256); + builder.HasIndex(o => new { o.FlagName, o.OverrideType, o.OverrideValue }).IsUnique(); + builder.Property(o => o.ConcurrencyStamp).HasMaxLength(40).IsConcurrencyToken(); + } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideEntity.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideEntity.cs new file mode 100644 index 00000000..087e9c40 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Entities/FeatureFlagOverrideEntity.cs @@ -0,0 +1,12 @@ +using SimpleModule.Core.Entities; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags.Entities; + +public class FeatureFlagOverrideEntity : Entity +{ + public string FlagName { get; set; } = string.Empty; + public OverrideType OverrideType { get; set; } + public string OverrideValue { get; set; } = string.Empty; + public bool IsEnabled { get; set; } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs new file mode 100644 index 00000000..63a0c4d5 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs @@ -0,0 +1,345 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.FeatureFlags; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.FeatureFlags.Entities; + +namespace SimpleModule.FeatureFlags; + +public sealed partial class FeatureFlagService( + FeatureFlagsDbContext db, + IFeatureFlagRegistry registry, + IMemoryCache cache, + ILogger logger +) : IFeatureFlagContracts, IFeatureFlagService +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); + private const string AllFlagDataCacheKey = "ff:all-data"; + + private sealed record FlagData( + bool IsEnabled, + Dictionary UserOverrides, + Dictionary RoleOverrides + ); + + public async Task IsEnabledAsync( + string flagName, + string? userId = null, + IEnumerable? roles = null + ) + { + var data = await GetFlagDataAsync(flagName); + return ResolveFlagState(data, userId, roles); + } + + public async Task> GetAllEnabledAsync( + string? userId = null, + IEnumerable? roles = null + ) + { + var allData = await GetAllFlagDataAsync(); + var rolesList = roles as IReadOnlyList ?? roles?.ToList(); + + var result = new Dictionary(allData.Count); + foreach (var (name, data) in allData) + { + result[name] = ResolveFlagState(data, userId, rolesList); + } + + return result; + } + + public async Task> GetAllFlagsAsync() + { + var definitions = registry.GetAllDefinitions(); + var dbFlags = await db.FeatureFlags.AsNoTracking().ToListAsync(); + var dbMap = dbFlags.ToDictionary(f => f.Name); + + var result = new List(); + var addedNames = new HashSet(definitions.Count, StringComparer.Ordinal); + + foreach (var def in definitions) + { + addedNames.Add(def.Name); + dbMap.TryGetValue(def.Name, out var entity); + result.Add(ToDto(def, entity)); + } + + // Include deprecated flags (in DB but not in registry) + foreach (var entity in dbFlags) + { + if (entity.IsDeprecated && !addedNames.Contains(entity.Name)) + { + result.Add( + new FeatureFlag + { + Name = entity.Name, + IsEnabled = entity.IsEnabled, + IsDeprecated = true, + UpdatedAt = entity.UpdatedAt, + } + ); + } + } + + return result; + } + + public async Task GetFlagAsync(string flagName) + { + var def = registry.GetDefinition(flagName); + var entity = await db + .FeatureFlags.AsNoTracking() + .FirstOrDefaultAsync(f => f.Name == flagName); + + if (def is null && entity is null) + { + return null; + } + + return ToDto(def, entity); + } + + public async Task UpdateFlagAsync( + string flagName, + UpdateFeatureFlagRequest request + ) + { + var entity = await db.FeatureFlags.FirstOrDefaultAsync(f => f.Name == flagName); + if (entity is null) + { + entity = new FeatureFlagEntity + { + Name = flagName, + IsEnabled = request.IsEnabled, + }; + db.FeatureFlags.Add(entity); + } + else + { + entity.IsEnabled = request.IsEnabled; + } + + await db.SaveChangesAsync(); + InvalidateCache(flagName); + + LogFlagToggled(logger, flagName, request.IsEnabled); + + var def = registry.GetDefinition(flagName); + return ToDto(def, entity); + } + + public async Task> GetOverridesAsync(string flagName) + { + return await db + .FeatureFlagOverrides.AsNoTracking() + .Where(o => o.FlagName == flagName) + .Select(o => new FeatureFlagOverride + { + Id = o.Id, + FlagName = o.FlagName, + OverrideType = o.OverrideType, + OverrideValue = o.OverrideValue, + IsEnabled = o.IsEnabled, + }) + .ToListAsync(); + } + + public async Task SetOverrideAsync( + string flagName, + SetOverrideRequest request + ) + { + var existing = await db.FeatureFlagOverrides.FirstOrDefaultAsync(o => + o.FlagName == flagName + && o.OverrideType == request.OverrideType + && o.OverrideValue == request.OverrideValue + ); + + if (existing is not null) + { + existing.IsEnabled = request.IsEnabled; + } + else + { + existing = new FeatureFlagOverrideEntity + { + FlagName = flagName, + OverrideType = request.OverrideType, + OverrideValue = request.OverrideValue, + IsEnabled = request.IsEnabled, + }; + db.FeatureFlagOverrides.Add(existing); + } + + await db.SaveChangesAsync(); + InvalidateCache(flagName); + + return new FeatureFlagOverride + { + Id = existing.Id, + FlagName = existing.FlagName, + OverrideType = existing.OverrideType, + OverrideValue = existing.OverrideValue, + IsEnabled = existing.IsEnabled, + }; + } + + public async Task DeleteOverrideAsync(int overrideId) + { + var entity = await db.FeatureFlagOverrides.FindAsync(overrideId); + if (entity is not null) + { + db.FeatureFlagOverrides.Remove(entity); + await db.SaveChangesAsync(); + InvalidateCache(entity.FlagName); + } + } + + private async Task> GetAllFlagDataAsync() + { + if ( + cache.TryGetValue(AllFlagDataCacheKey, out Dictionary? cached) + && cached is not null + ) + { + return cached; + } + + var definitions = registry.GetAllDefinitions(); + var flagNames = definitions.Select(d => d.Name).ToList(); + + var dbFlags = await db + .FeatureFlags.AsNoTracking() + .Where(f => flagNames.Contains(f.Name)) + .ToDictionaryAsync(f => f.Name); + + var dbOverrides = await db + .FeatureFlagOverrides.AsNoTracking() + .Where(o => flagNames.Contains(o.FlagName)) + .ToListAsync(); + + var overridesByFlag = dbOverrides + .GroupBy(o => o.FlagName) + .ToDictionary(g => g.Key, g => g.ToList()); + + var allData = new Dictionary(flagNames.Count, StringComparer.Ordinal); + foreach (var def in definitions) + { + var isEnabled = dbFlags.TryGetValue(def.Name, out var entity) + ? entity.IsEnabled + : def.DefaultEnabled; + + var flagOverrides = overridesByFlag.GetValueOrDefault(def.Name, []); + var data = BuildFlagData(isEnabled, flagOverrides); + + allData[def.Name] = data; + cache.Set($"ff:data:{def.Name}", data, CacheDuration); + } + + cache.Set(AllFlagDataCacheKey, allData, CacheDuration); + return allData; + } + + private async Task GetFlagDataAsync(string flagName) + { + var cacheKey = $"ff:data:{flagName}"; + if (cache.TryGetValue(cacheKey, out FlagData? cached) && cached is not null) + { + return cached; + } + + var flag = await db + .FeatureFlags.AsNoTracking() + .FirstOrDefaultAsync(f => f.Name == flagName); + + var isEnabled = flag?.IsEnabled ?? registry.GetDefinition(flagName)?.DefaultEnabled ?? false; + + var overrides = await db + .FeatureFlagOverrides.AsNoTracking() + .Where(o => o.FlagName == flagName) + .ToListAsync(); + + var data = BuildFlagData(isEnabled, overrides); + cache.Set(cacheKey, data, CacheDuration); + return data; + } + + private static FlagData BuildFlagData( + bool isEnabled, + List overrides + ) + { + var userOverrides = new Dictionary(StringComparer.Ordinal); + var roleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var o in overrides) + { + if (o.OverrideType == OverrideType.User) + { + userOverrides[o.OverrideValue] = o.IsEnabled; + } + else + { + roleOverrides[o.OverrideValue] = o.IsEnabled; + } + } + + return new FlagData(isEnabled, userOverrides, roleOverrides); + } + + private static bool ResolveFlagState( + FlagData data, + string? userId, + IEnumerable? roles + ) + { + if (userId is not null && data.UserOverrides.TryGetValue(userId, out var userEnabled)) + { + return userEnabled; + } + + if (roles is not null) + { + foreach (var role in roles) + { + if (data.RoleOverrides.TryGetValue(role, out var roleEnabled)) + { + return roleEnabled; + } + } + } + + return data.IsEnabled; + } + + private static FeatureFlag ToDto(FeatureFlagDefinition? def, FeatureFlagEntity? entity) + { + return new FeatureFlag + { + Name = def?.Name ?? entity?.Name ?? string.Empty, + Description = def?.Description ?? string.Empty, + IsEnabled = entity?.IsEnabled ?? def?.DefaultEnabled ?? false, + DefaultEnabled = def?.DefaultEnabled ?? false, + IsDeprecated = entity?.IsDeprecated ?? false, + UpdatedAt = entity?.UpdatedAt ?? default, + }; + } + + private void InvalidateCache(string flagName) + { + cache.Remove($"ff:data:{flagName}"); + cache.Remove(AllFlagDataCacheKey); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Feature flag '{FlagName}' toggled to {IsEnabled}" + )] + private static partial void LogFlagToggled( + ILogger logger, + string flagName, + bool isEnabled + ); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs new file mode 100644 index 00000000..f5ff22d7 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs @@ -0,0 +1,89 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.FeatureFlags; +using SimpleModule.FeatureFlags.Entities; + +namespace SimpleModule.FeatureFlags; + +public sealed partial class FeatureFlagSyncService( + IServiceProvider serviceProvider, + IFeatureFlagRegistry registry, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Ensure module tables exist. EnsureCreated is a no-op when the database + // file already exists (created by the Host DbContext), so we call + // CreateTables directly and ignore the error if tables already exist. + try + { + var creator = db.Database.GetService(); + await creator.CreateTablesAsync(cancellationToken); + } + catch (DbException) + { + // Tables already exist — expected in normal operation + } + + var definitions = registry.GetAllDefinitions(); + var existingFlags = await db.FeatureFlags.ToListAsync(cancellationToken); + var existingNames = existingFlags.ToDictionary(f => f.Name); + + var newCount = 0; + var deprecatedCount = 0; + + // Create DB rows for new features + foreach (var def in definitions) + { + if (!existingNames.ContainsKey(def.Name)) + { + db.FeatureFlags.Add( + new FeatureFlagEntity + { + Name = def.Name, + IsEnabled = def.DefaultEnabled, + } + ); + newCount++; + } + } + + // Mark removed features as deprecated + var registryNames = registry.GetAllFeatureNames(); + foreach (var entity in existingFlags) + { + if (!registryNames.Contains(entity.Name) && !entity.IsDeprecated) + { + entity.IsDeprecated = true; + deprecatedCount++; + } + } + + if (newCount > 0 || deprecatedCount > 0) + { + await db.SaveChangesAsync(cancellationToken); + LogSync(logger, newCount, deprecatedCount); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Feature flag sync: {NewCount} new, {DeprecatedCount} deprecated" + )] + private static partial void LogSync( + ILogger logger, + int newCount, + int deprecatedCount + ); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsDbContext.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsDbContext.cs new file mode 100644 index 00000000..b83cd4a5 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.FeatureFlags.Entities; + +namespace SimpleModule.FeatureFlags; + +public class FeatureFlagsDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet FeatureFlags => Set(); + public DbSet FeatureFlagOverrides => + Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(FeatureFlagsDbContext).Assembly); + modelBuilder.ApplyModuleSchema("FeatureFlags", dbOptions.Value); + } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs new file mode 100644 index 00000000..8d7be7be --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.FeatureFlags; +using SimpleModule.Core.Menu; +using SimpleModule.Database; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags; + +[Module( + FeatureFlagsConstants.ModuleName, + RoutePrefix = FeatureFlagsConstants.RoutePrefix, + ViewPrefix = "/feature-flags" +)] +public class FeatureFlagsModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddMemoryCache(); + services.AddModuleDbContext( + configuration, + FeatureFlagsConstants.ModuleName + ); + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService() + ); + services.AddScoped(sp => + sp.GetRequiredService() + ); + services.AddHostedService(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Feature Flags", + Url = "/feature-flags", + Icon = + """""", + Order = 60, + Section = MenuSection.AdminSidebar, + } + ); + } + + public void ConfigureMiddleware(IApplicationBuilder app) + { + app.UseMiddleware(); + } + +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsPermissions.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsPermissions.cs new file mode 100644 index 00000000..8d69133c --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsPermissions.cs @@ -0,0 +1,9 @@ +using SimpleModule.Core.Authorization; + +namespace SimpleModule.FeatureFlags; + +public sealed class FeatureFlagsPermissions : IModulePermissions +{ + public const string View = "FeatureFlags.View"; + public const string Manage = "FeatureFlags.Manage"; +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs new file mode 100644 index 00000000..ebaa4c0a --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.FeatureFlags; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.FeatureFlags.Middleware; + +public sealed class FeatureFlagMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var sharedData = context.RequestServices.GetService(); + var featureFlagService = context.RequestServices.GetService(); + + if (sharedData is not null && featureFlagService is not null) + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + var roles = context.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); + + var flags = await featureFlagService.GetAllEnabledAsync(userId, roles); + + sharedData.Set("featureFlags", flags); + } + + await next(context); + } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Pages/index.ts b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Pages/index.ts new file mode 100644 index 00000000..a9726859 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Pages/index.ts @@ -0,0 +1,3 @@ +export const pages: Record = { + 'FeatureFlags/Manage': () => import('../Views/Manage'), +}; diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.csproj b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.csproj new file mode 100644 index 00000000..5a667b10 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.csproj @@ -0,0 +1,11 @@ + + + net10.0 + + + + + + + + diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/Manage.tsx b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/Manage.tsx new file mode 100644 index 00000000..58f413b5 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/Manage.tsx @@ -0,0 +1,243 @@ +import { + Badge, + Button, + Card, + CardContent, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Field, + FieldGroup, + Input, + Label, + PageShell, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import { OVERRIDE_TYPE_ROLE, OVERRIDE_TYPE_USER } from '../constants'; +import type { FeatureFlag, FeatureFlagOverride } from '../types'; + +interface ManageProps { + flags: FeatureFlag[]; +} + +export default function Manage({ flags: initialFlags }: ManageProps) { + const [flags, setFlags] = useState(initialFlags); + const [selectedFlag, setSelectedFlag] = useState(null); + const [overrides, setOverrides] = useState([]); + const [overrideDialogOpen, setOverrideDialogOpen] = useState(false); + + const handleToggle = async (name: string, enabled: boolean) => { + const response = await fetch(`/api/feature-flags/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isEnabled: enabled }), + }); + if (response.ok) { + setFlags((prev) => prev.map((f) => (f.name === name ? { ...f, isEnabled: enabled } : f))); + } + }; + + const loadOverrides = async (name: string) => { + const response = await fetch(`/api/feature-flags/${encodeURIComponent(name)}/overrides`); + if (response.ok) { + const data = await response.json(); + setOverrides(data); + setSelectedFlag(name); + setOverrideDialogOpen(true); + } + }; + + const handleAddOverride = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedFlag) return; + + const formData = new FormData(e.currentTarget); + const overrideType = Number(formData.get('overrideType')); + const overrideValue = formData.get('overrideValue') as string; + const isEnabled = formData.get('isEnabled') === 'on'; + + const response = await fetch( + `/api/feature-flags/${encodeURIComponent(selectedFlag)}/overrides`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overrideType, overrideValue, isEnabled }), + }, + ); + + if (response.ok) { + const newOverride: FeatureFlagOverride = await response.json(); + setOverrides((prev) => [...prev, newOverride]); + e.currentTarget.reset(); + } + }; + + const handleDeleteOverride = async (id: number) => { + const response = await fetch(`/api/feature-flags/overrides/${id}`, { method: 'DELETE' }); + if (response.ok && selectedFlag) { + setOverrides((prev) => prev.filter((o) => o.id !== id)); + } + }; + + const activeFlags = flags.filter((f) => !f.isDeprecated); + const deprecatedFlags = flags.filter((f) => f.isDeprecated); + + return ( + + + + + + + Name + Description + Default + Enabled + Overrides + + + + {activeFlags.map((flag) => ( + + {flag.name} + {flag.description} + + + {flag.defaultEnabled ? 'on' : 'off'} + + + + handleToggle(flag.name, checked)} + /> + + + + + + ))} + +
+
+
+ + {deprecatedFlags.length > 0 && ( + + +

Deprecated Flags

+ + + + Name + Enabled + + + + {deprecatedFlags.map((flag) => ( + + + {flag.name}{' '} + + deprecated + + + + handleToggle(flag.name, checked)} + /> + + + ))} + +
+
+
+ )} + + + + + Overrides for {selectedFlag} + + + {overrides.length > 0 && ( + + + + Type + Value + Enabled + + + + + {overrides.map((o) => ( + + + {o.overrideType === OVERRIDE_TYPE_USER ? 'User' : 'Role'} + + {o.overrideValue} + {o.isEnabled ? 'Yes' : 'No'} + + + + + ))} + +
+ )} + +
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/ManageEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/ManageEndpoint.cs new file mode 100644 index 00000000..852b92a1 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Views/ManageEndpoint.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.FeatureFlags.Contracts; + +namespace SimpleModule.FeatureFlags.Views; + +[ViewPage("FeatureFlags/Manage")] +public class ManageEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/", + async (IFeatureFlagContracts featureFlags) => + { + var flags = await featureFlags.GetAllFlagsAsync(); + return Inertia.Render("FeatureFlags/Manage", new { flags }); + } + ) + .RequirePermission(FeatureFlagsPermissions.View); + } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/constants.ts b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/constants.ts new file mode 100644 index 00000000..6ff2070e --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/constants.ts @@ -0,0 +1,3 @@ +export const OVERRIDE_TYPE_USER = 0 as const; +export const OVERRIDE_TYPE_ROLE = 1 as const; +export type OverrideType = typeof OVERRIDE_TYPE_USER | typeof OVERRIDE_TYPE_ROLE; diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/package.json b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/package.json new file mode 100644 index 00000000..109b1fbc --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "@simplemodule/feature-flags", + "version": "0.0.0", + "scripts": { + "build": "cross-env VITE_MODE=prod vite build", + "build:dev": "cross-env VITE_MODE=dev vite build", + "watch": "cross-env VITE_MODE=dev vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/types.ts b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/types.ts new file mode 100644 index 00000000..09cf293d --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/types.ts @@ -0,0 +1,28 @@ +// Auto-generated from [Dto] types — do not edit +export interface FeatureFlag { + name: string; + description: string; + isEnabled: boolean; + defaultEnabled: boolean; + isDeprecated: boolean; + updatedAt: string; +} + +export interface FeatureFlagOverride { + id: number; + flagName: string; + overrideType: any; + overrideValue: string; + isEnabled: boolean; +} + +export interface SetOverrideRequest { + overrideType: any; + overrideValue: string; + isEnabled: boolean; +} + +export interface UpdateFeatureFlagRequest { + isEnabled: boolean; +} + diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/vite.config.ts b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/vite.config.ts new file mode 100644 index 00000000..a3d37c71 --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/vite.config.ts @@ -0,0 +1,3 @@ +import { defineModuleConfig } from '@simplemodule/client/module'; + +export default defineModuleConfig(__dirname); diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/GlobalUsings.cs b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Integration/FeatureFlagEndpointTests.cs b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Integration/FeatureFlagEndpointTests.cs new file mode 100644 index 00000000..3e624a1e --- /dev/null +++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Integration/FeatureFlagEndpointTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using SimpleModule.FeatureFlags; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.Tests.Shared.Fixtures; + +namespace FeatureFlags.Tests.Integration; + +public class FeatureFlagEndpointTests(SimpleModuleWebApplicationFactory factory) + : IClassFixture +{ + private static readonly string[] ViewPermission = [FeatureFlagsPermissions.View]; + + private static readonly string[] AllPermissions = + [ + FeatureFlagsPermissions.View, + FeatureFlagsPermissions.Manage, + ]; + + [Fact] + public async Task GetAllFlags_WithPermission_Returns200() + { + var client = factory.CreateAuthenticatedClient(ViewPermission); + var response = await client.GetAsync("/api/feature-flags"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetAllFlags_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/feature-flags"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetAllFlags_NoPermission_Returns403() + { + var client = factory.CreateAuthenticatedClient(); + var response = await client.GetAsync("/api/feature-flags"); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task UpdateFlag_WithPermission_Returns200() + { + var client = factory.CreateAuthenticatedClient(AllPermissions); + + // Create/update a flag directly by name + var request = new UpdateFeatureFlagRequest { IsEnabled = true }; + var response = await client.PutAsJsonAsync( + "/api/feature-flags/Integration.TestFlag", + request + ); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CheckFlag_Authenticated_Returns200() + { + var client = factory.CreateAuthenticatedClient(ViewPermission); + var response = await client.GetAsync("/api/feature-flags/check/nonexistent.flag"); + var body = await response.Content.ReadAsStringAsync(); + Assert.True( + response.StatusCode == HttpStatusCode.OK, + $"Expected 200 but got {(int)response.StatusCode}. Body: {body}" + ); + } + + [Fact] + public async Task SetOverride_WithPermission_Returns201() + { + var client = factory.CreateAuthenticatedClient(AllPermissions); + + // Ensure flag exists first + await client.PutAsJsonAsync( + "/api/feature-flags/Integration.OverrideTest", + new UpdateFeatureFlagRequest { IsEnabled = false } + ); + + var request = new SetOverrideRequest + { + OverrideType = OverrideType.User, + OverrideValue = "test-user-id", + IsEnabled = true, + }; + var response = await client.PostAsJsonAsync( + "/api/feature-flags/Integration.OverrideTest/overrides", + request + ); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteOverride_WithPermission_Returns204() + { + var client = factory.CreateAuthenticatedClient(AllPermissions); + + // Ensure flag exists first + await client.PutAsJsonAsync( + "/api/feature-flags/Integration.DeleteTest", + new UpdateFeatureFlagRequest { IsEnabled = false } + ); + + var setRequest = new SetOverrideRequest + { + OverrideType = OverrideType.Role, + OverrideValue = "test-role-delete", + IsEnabled = true, + }; + var setResponse = await client.PostAsJsonAsync( + "/api/feature-flags/Integration.DeleteTest/overrides", + setRequest + ); + setResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await setResponse.Content.ReadFromJsonAsync(); + + created.Should().NotBeNull(); + var deleteResponse = await client.DeleteAsync( + $"/api/feature-flags/overrides/{created!.Id}" + ); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } +} diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/SimpleModule.FeatureFlags.Tests.csproj b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/SimpleModule.FeatureFlags.Tests.csproj new file mode 100644 index 00000000..e0e9cf41 --- /dev/null +++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/SimpleModule.FeatureFlags.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + + diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs new file mode 100644 index 00000000..5437989c --- /dev/null +++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs @@ -0,0 +1,219 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleModule.Core.FeatureFlags; +using SimpleModule.Database; +using SimpleModule.FeatureFlags; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.FeatureFlags.Entities; + +namespace FeatureFlags.Tests.Unit; + +public sealed class FeatureFlagServiceTests : IDisposable +{ + private readonly FeatureFlagsDbContext _db; + private readonly FeatureFlagService _sut; + private readonly IFeatureFlagRegistry _registry; + private readonly MemoryCache _cache; + private readonly List _freshCaches = []; + + public FeatureFlagServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var dbOptions = Options.Create( + new DatabaseOptions { DefaultConnection = "Data Source=:memory:" } + ); + _db = new FeatureFlagsDbContext(options, dbOptions); + _db.Database.EnsureCreated(); + + var builder = new FeatureFlagRegistryBuilder(); + builder.AddDefinition( + new FeatureFlagDefinition + { + Name = "Test.FeatureA", + Description = "Test feature A", + DefaultEnabled = true, + } + ); + builder.AddDefinition( + new FeatureFlagDefinition + { + Name = "Test.FeatureB", + Description = "Test feature B", + DefaultEnabled = false, + } + ); + _registry = builder.Build(); + + _cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + _sut = new FeatureFlagService( + _db, + _registry, + _cache, + NullLogger.Instance + ); + } + + public void Dispose() + { + foreach (var c in _freshCaches) + { + c.Dispose(); + } + + _cache.Dispose(); + _db.Dispose(); + } + + private FeatureFlagService CreateFreshService() + { + // Create a new cache to bypass cached results + var freshCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + _freshCaches.Add(freshCache); + return new FeatureFlagService( + _db, + _registry, + freshCache, + NullLogger.Instance + ); + } + + [Fact] + public async Task IsEnabledAsync_NoDbRow_ReturnsRegistryDefault() + { + var result = await _sut.IsEnabledAsync("Test.FeatureA"); + result.Should().BeTrue(); + + var result2 = await _sut.IsEnabledAsync("Test.FeatureB"); + result2.Should().BeFalse(); + } + + [Fact] + public async Task IsEnabledAsync_WithDbRow_ReturnsDbState() + { + _db.FeatureFlags.Add( + new FeatureFlagEntity + { + Name = "Test.FeatureA", + IsEnabled = false, + } + ); + await _db.SaveChangesAsync(); + + // Use a fresh service to bypass cache + var sut = CreateFreshService(); + + var result = await sut.IsEnabledAsync("Test.FeatureA"); + result.Should().BeFalse(); + } + + [Fact] + public async Task IsEnabledAsync_WithUserOverride_ReturnsOverride() + { + _db.FeatureFlags.Add( + new FeatureFlagEntity + { + Name = "Test.FeatureB", + IsEnabled = false, + } + ); + _db.FeatureFlagOverrides.Add( + new FeatureFlagOverrideEntity + { + FlagName = "Test.FeatureB", + OverrideType = OverrideType.User, + OverrideValue = "user-1", + IsEnabled = true, + } + ); + await _db.SaveChangesAsync(); + + var sut = CreateFreshService(); + + var result = await sut.IsEnabledAsync("Test.FeatureB", "user-1"); + result.Should().BeTrue(); + } + + [Fact] + public async Task IsEnabledAsync_WithRoleOverride_ReturnsOverride() + { + _db.FeatureFlags.Add( + new FeatureFlagEntity + { + Name = "Test.FeatureB", + IsEnabled = false, + } + ); + _db.FeatureFlagOverrides.Add( + new FeatureFlagOverrideEntity + { + FlagName = "Test.FeatureB", + OverrideType = OverrideType.Role, + OverrideValue = "Beta", + IsEnabled = true, + } + ); + await _db.SaveChangesAsync(); + + var sut = CreateFreshService(); + + var result = await sut.IsEnabledAsync("Test.FeatureB", null, ["Beta"]); + result.Should().BeTrue(); + } + + [Fact] + public async Task IsEnabledAsync_UserOverrideTakesPrecedenceOverRole() + { + _db.FeatureFlagOverrides.Add( + new FeatureFlagOverrideEntity + { + FlagName = "Test.FeatureA", + OverrideType = OverrideType.Role, + OverrideValue = "Beta", + IsEnabled = true, + } + ); + _db.FeatureFlagOverrides.Add( + new FeatureFlagOverrideEntity + { + FlagName = "Test.FeatureA", + OverrideType = OverrideType.User, + OverrideValue = "user-1", + IsEnabled = false, + } + ); + await _db.SaveChangesAsync(); + + var sut = CreateFreshService(); + + var result = await sut.IsEnabledAsync("Test.FeatureA", "user-1", ["Beta"]); + result.Should().BeFalse(); + } + + [Fact] + public async Task GetAllFlagsAsync_ReturnsMergedList() + { + var flags = await _sut.GetAllFlagsAsync(); + flags.Should().HaveCount(2); + } + + [Fact] + public async Task UpdateFlagAsync_CreatesAndUpdates() + { + var result = await _sut.UpdateFlagAsync( + "Test.FeatureA", + new UpdateFeatureFlagRequest { IsEnabled = false } + ); + result.IsEnabled.Should().BeFalse(); + + var updated = await _sut.UpdateFlagAsync( + "Test.FeatureA", + new UpdateFeatureFlagRequest { IsEnabled = true } + ); + updated.IsEnabled.Should().BeTrue(); + } +} diff --git a/package-lock.json b/package-lock.json index 8bca764e..a770bcfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,14 @@ "react-dom": "^19.0.0" } }, + "modules/FeatureFlags/src/SimpleModule.FeatureFlags": { + "name": "@simplemodule/feature-flags", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/FileStorage/src/FileStorage": { "name": "@simplemodule/filestorage", "version": "0.0.0", @@ -3492,6 +3500,10 @@ "resolved": "tests/e2e", "link": true }, + "node_modules/@simplemodule/feature-flags": { + "resolved": "modules/FeatureFlags/src/SimpleModule.FeatureFlags", + "link": true + }, "node_modules/@simplemodule/filestorage": { "resolved": "modules/FileStorage/src/SimpleModule.FileStorage", "link": true diff --git a/packages/SimpleModule.UI/hooks/use-feature-flag.ts b/packages/SimpleModule.UI/hooks/use-feature-flag.ts new file mode 100644 index 00000000..be3b3292 --- /dev/null +++ b/packages/SimpleModule.UI/hooks/use-feature-flag.ts @@ -0,0 +1,6 @@ +import { usePage } from '@inertiajs/react'; + +export function useFeatureFlag(name: string): boolean { + const { featureFlags } = usePage<{ featureFlags?: Record }>().props; + return featureFlags?.[name] ?? false; +} diff --git a/packages/SimpleModule.UI/package.json b/packages/SimpleModule.UI/package.json index bf105794..c789928b 100644 --- a/packages/SimpleModule.UI/package.json +++ b/packages/SimpleModule.UI/package.json @@ -5,7 +5,8 @@ "main": "components/index.ts", "exports": { ".": "./components/index.ts", - "./lib/utils": "./lib/utils.ts" + "./lib/utils": "./lib/utils.ts", + "./hooks": "./hooks/use-feature-flag.ts" }, "peerDependencies": { "react": "^19.0.0", diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index e85dc158..deb59e6f 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -30,6 +30,7 @@ + diff --git a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs index 31277c9e..76bdbd50 100644 --- a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs +++ b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs @@ -203,6 +203,7 @@ public void SortModules_WithDependencies_ReordersByDependency() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -218,6 +219,7 @@ public void SortModules_WithDependencies_ReordersByDependency() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -232,6 +234,7 @@ public void SortModules_WithDependencies_ReordersByDependency() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, @@ -261,6 +264,7 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -276,6 +280,7 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -293,6 +298,7 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, @@ -322,6 +328,7 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -337,6 +344,7 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -352,6 +360,7 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() false, false, false, + false, "", "", ImmutableArray.Empty, @@ -366,6 +375,7 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 49255490..90668cfb 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,6 +22,7 @@ using SimpleModule.Permissions; using SimpleModule.Products; using SimpleModule.Settings; +using SimpleModule.FeatureFlags; using SimpleModule.Users; namespace SimpleModule.Tests.Shared.Fixtures; @@ -58,6 +60,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); + ReplaceDbContext(services); ReplaceDbContext(services, useOpenIddict: true); // Remove hosted seed services — they need real DB tables that @@ -67,6 +70,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) RemoveHostedService(services); RemoveHostedService(services); RemoveHostedService(services); + RemoveHostedService(services); // Add test authentication scheme that bypasses OpenIddict validation services @@ -140,17 +144,41 @@ private void EnsureModuleDatabasesCreated() { using var scope = Services.CreateScope(); var sp = scope.ServiceProvider; + // HostDbContext includes all entities with module prefixes — it creates all tables. sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); - sp.GetRequiredService().Database.EnsureCreated(); + // Some module contexts may need explicit table creation if EnsureCreated + // returns false (database already has tables from HostDbContext startup). + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + EnsureTablesCreated(sp); + } + + private static void EnsureTablesCreated(IServiceProvider sp) + where TContext : DbContext + { + var db = sp.GetRequiredService(); + if (!db.Database.EnsureCreated()) + { + // DB already exists but tables for this context may not. + // Force table creation via the relational creator. + try + { + db.GetService() + ?.CreateTables(); + } + catch (Microsoft.Data.Sqlite.SqliteException) + { + // Tables already exist — ignore + } + } } private static void RemoveHostedService(IServiceCollection services) diff --git a/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj b/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj index 3372a857..4a964a90 100644 --- a/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj +++ b/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/e2e/pages/feature-flags/manage.page.ts b/tests/e2e/pages/feature-flags/manage.page.ts new file mode 100644 index 00000000..ca9890df --- /dev/null +++ b/tests/e2e/pages/feature-flags/manage.page.ts @@ -0,0 +1,49 @@ +import type { Page } from '@playwright/test'; + +export class FeatureFlagsManagePage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/feature-flags'); + } + + get heading() { + return this.page.getByRole('heading', { name: /feature flags/i }); + } + + get flagTable() { + return this.page.getByRole('table'); + } + + get flagRows() { + return this.page.getByRole('row'); + } + + flagRow(name: string) { + return this.page.getByRole('row', { name: new RegExp(name, 'i') }); + } + + flagToggle(name: string) { + return this.flagRow(name).getByRole('switch'); + } + + overridesButton(name: string) { + return this.flagRow(name).getByRole('button', { name: /overrides/i }); + } + + get overrideDialog() { + return this.page.getByRole('dialog'); + } + + get addOverrideButton() { + return this.overrideDialog.getByRole('button', { name: /add override/i }); + } + + get overrideValueInput() { + return this.overrideDialog.getByLabel(/value/i); + } + + get deprecatedSection() { + return this.page.getByText(/deprecated/i); + } +} diff --git a/tests/e2e/tests/flows/feature-flags-crud.spec.ts b/tests/e2e/tests/flows/feature-flags-crud.spec.ts new file mode 100644 index 00000000..41d6540a --- /dev/null +++ b/tests/e2e/tests/flows/feature-flags-crud.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from '../../fixtures/base'; +import { FeatureFlagsManagePage } from '../../pages/feature-flags/manage.page'; + +test.describe('Feature Flags CRUD flows', () => { + test('toggle a feature flag and verify state persists', async ({ page, request }) => { + const manage = new FeatureFlagsManagePage(page); + + // API: get all flags to find one to toggle + const flagsRes = await request.get('/api/feature-flags'); + expect(flagsRes.ok()).toBeTruthy(); + const flags = await flagsRes.json(); + expect(flags.length).toBeGreaterThan(0); + + const flag = flags[0]; + const originalState = flag.isEnabled; + + // API: toggle the flag + const toggleRes = await request.put(`/api/feature-flags/${encodeURIComponent(flag.name)}`, { + data: { isEnabled: !originalState }, + }); + expect(toggleRes.ok()).toBeTruthy(); + + // API: verify the state changed + const afterToggleRes = await request.get('/api/feature-flags'); + const afterToggle = await afterToggleRes.json(); + const toggled = afterToggle.find((f: { name: string }) => f.name === flag.name); + expect(toggled.isEnabled).toBe(!originalState); + + // UI: verify the state on the manage page + await manage.goto(); + await expect(manage.flagRow(flag.name)).toBeVisible(); + + // API: restore original state + await request.put(`/api/feature-flags/${encodeURIComponent(flag.name)}`, { + data: { isEnabled: originalState }, + }); + }); + + test('add and remove an override via API', async ({ request }) => { + // API: get a flag to work with + const flagsRes = await request.get('/api/feature-flags'); + const flags = await flagsRes.json(); + expect(flags.length).toBeGreaterThan(0); + const flag = flags[0]; + + // API: add a user override + const addRes = await request.post( + `/api/feature-flags/${encodeURIComponent(flag.name)}/overrides`, + { + data: { + overrideType: 0, // User + overrideValue: 'e2e-test-user', + isEnabled: true, + }, + }, + ); + expect(addRes.ok()).toBeTruthy(); + const override = await addRes.json(); + expect(override.flagName).toBe(flag.name); + expect(override.overrideValue).toBe('e2e-test-user'); + + // API: verify override appears in list + const overridesRes = await request.get( + `/api/feature-flags/${encodeURIComponent(flag.name)}/overrides`, + ); + expect(overridesRes.ok()).toBeTruthy(); + const overrides = await overridesRes.json(); + expect( + overrides.some((o: { overrideValue: string }) => o.overrideValue === 'e2e-test-user'), + ).toBeTruthy(); + + // API: delete the override + const deleteRes = await request.delete(`/api/feature-flags/overrides/${override.id}`); + expect(deleteRes.ok()).toBeTruthy(); + + // API: verify override is gone + const afterDeleteRes = await request.get( + `/api/feature-flags/${encodeURIComponent(flag.name)}/overrides`, + ); + const afterDelete = await afterDeleteRes.json(); + expect( + afterDelete.some((o: { overrideValue: string }) => o.overrideValue === 'e2e-test-user'), + ).toBeFalsy(); + }); + + test('check endpoint returns flag state for current user', async ({ request }) => { + // API: get a flag + const flagsRes = await request.get('/api/feature-flags'); + const flags = await flagsRes.json(); + expect(flags.length).toBeGreaterThan(0); + const flag = flags[0]; + + // API: check endpoint + const checkRes = await request.get(`/api/feature-flags/check/${encodeURIComponent(flag.name)}`); + expect(checkRes.ok()).toBeTruthy(); + const result = await checkRes.json(); + expect(typeof result.isEnabled).toBe('boolean'); + }); + + test('overrides dialog opens from manage page', async ({ page, request }) => { + const manage = new FeatureFlagsManagePage(page); + + // API: get a flag name + const flagsRes = await request.get('/api/feature-flags'); + const flags = await flagsRes.json(); + expect(flags.length).toBeGreaterThan(0); + const flagName = flags[0].name; + + // UI: navigate and open overrides dialog + await manage.goto(); + await manage.overridesButton(flagName).click(); + await expect(manage.overrideDialog).toBeVisible(); + await expect(manage.addOverrideButton).toBeVisible(); + }); +}); diff --git a/tests/e2e/tests/smoke/feature-flags.spec.ts b/tests/e2e/tests/smoke/feature-flags.spec.ts new file mode 100644 index 00000000..f827ac8f --- /dev/null +++ b/tests/e2e/tests/smoke/feature-flags.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '../../fixtures/base'; +import { FeatureFlagsManagePage } from '../../pages/feature-flags/manage.page'; + +test.describe('Feature Flags pages', () => { + test('manage page loads', async ({ page }) => { + const manage = new FeatureFlagsManagePage(page); + await manage.goto(); + await expect(manage.heading).toBeVisible(); + }); + + test('manage page shows flag table', async ({ page }) => { + const manage = new FeatureFlagsManagePage(page); + await manage.goto(); + await expect(manage.flagTable).toBeVisible(); + }); +}); From f9801f001dde86870f47395ade9b35cde657379f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 23:03:24 +0000 Subject: [PATCH 2/2] fix: add FeatureFlags migration and harden middleware error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EF Core migration (AddFeatureFlagsModule) to create the FeatureFlags and FeatureFlagOverrides tables - Catch DbException in FeatureFlagMiddleware so a missing or unreachable database does not crash the entire request pipeline with a 500 — feature flags degrade gracefully instead - Update HostDbContext model snapshot to include FeatureFlags entities --- .../Middleware/FeatureFlagMiddleware.cs | 18 +- ...29222047_AddFeatureFlagsModule.Designer.cs | 1264 +++++++++++++++++ .../20260329222047_AddFeatureFlagsModule.cs | 74 + .../Migrations/HostDbContextModelSnapshot.cs | 99 +- 4 files changed, 1441 insertions(+), 14 deletions(-) create mode 100644 template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.Designer.cs create mode 100644 template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.cs diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs index ebaa4c0a..83ed3d4f 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using SimpleModule.Core.FeatureFlags; using SimpleModule.Core.Inertia; @@ -15,12 +16,21 @@ public async Task InvokeAsync(HttpContext context) if (sharedData is not null && featureFlagService is not null) { - var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); - var roles = context.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); + try + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + var roles = context.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); - var flags = await featureFlagService.GetAllEnabledAsync(userId, roles); + var flags = await featureFlagService.GetAllEnabledAsync(userId, roles); - sharedData.Set("featureFlags", flags); + sharedData.Set("featureFlags", flags); + } + catch (System.Data.Common.DbException ex) + { + var logger = context.RequestServices.GetService() + ?.CreateLogger(); + logger?.LogWarning(ex, "Failed to load feature flags for shared data"); + } } await next(context); diff --git a/template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.Designer.cs new file mode 100644 index 00000000..6615f339 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.Designer.cs @@ -0,0 +1,1264 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260329222047_AddFeatureFlagsModule")] + partial class AddFeatureFlagsModule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Admin.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("Admin_AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.AuditLogs.Contracts.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("QueryString") + .HasColumnType("TEXT"); + + b.Property("RequestBody") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("Path"); + + b.HasIndex("Source"); + + b.HasIndex("StatusCode"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("Module", "Timestamp") + .IsDescending(false, true); + + b.HasIndex("UserId", "Timestamp") + .IsDescending(false, true); + + b.ToTable("AuditLogs_AuditEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Folder") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("Folder", "FileName") + .IsUnique(); + + b.ToTable("FileStorage_StoredFiles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Orders_Orders", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), + Total = 4205.85m, + UserId = "2" + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), + Total = 2752.49m, + UserId = "8" + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), + Total = 192.31m, + UserId = "6" + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), + Total = 3146.31m, + UserId = "2" + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), + Total = 4580.92m, + UserId = "10" + }); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("INTEGER"); + + b.Property("ProductId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("Orders_OrderItems", (string)null); + + b.HasData( + new + { + OrderId = 1, + ProductId = 4, + Quantity = 5 + }, + new + { + OrderId = 1, + ProductId = 5, + Quantity = 1 + }, + new + { + OrderId = 1, + ProductId = 6, + Quantity = 1 + }, + new + { + OrderId = 2, + ProductId = 1, + Quantity = 3 + }, + new + { + OrderId = 3, + ProductId = 4, + Quantity = 2 + }, + new + { + OrderId = 3, + ProductId = 5, + Quantity = 4 + }, + new + { + OrderId = 4, + ProductId = 3, + Quantity = 1 + }, + new + { + OrderId = 5, + ProductId = 9, + Quantity = 3 + }, + new + { + OrderId = 5, + ProductId = 6, + Quantity = 5 + }); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DraftContent") + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("MetaDescription") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MetaKeywords") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OgImage") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Order") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("IsPublished"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("PageBuilder_Pages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PageId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PageId"); + + b.ToTable("PageBuilder_Tags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PageBuilder_Templates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products_Products", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 991.68m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 446.22m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 660.12m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 633.67m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 674.30m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 851.63m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 417.48m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 975.56m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 928.09m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 592.60m + }); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CssClass") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsHomePage") + .HasColumnType("INTEGER"); + + b.Property("IsVisible") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OpenInNewTab") + .HasColumnType("INTEGER"); + + b.Property("PageRoute") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId", "SortOrder"); + + b.ToTable("Settings_PublicMenuItems", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Scope", "UserId") + .IsUnique(); + + b.ToTable("Settings_Settings", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Users_AspNetRoles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users_AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.HasOne("SimpleModule.Orders.Contracts.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => + { + b.HasOne("SimpleModule.PageBuilder.Contracts.Page", null) + .WithMany("Tags") + .HasForeignKey("PageId"); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.HasOne("SimpleModule.Settings.Entities.PublicMenuItemEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => + { + b.Navigation("Tags"); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.Navigation("Children"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.cs b/template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.cs new file mode 100644 index 00000000..65afc896 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260329222047_AddFeatureFlagsModule.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + /// + public partial class AddFeatureFlagsModule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FeatureFlags_FeatureFlags", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + IsDeprecated = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + ConcurrencyStamp = table.Column(type: "TEXT", maxLength: 40, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeatureFlags_FeatureFlags", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FeatureFlags_FeatureFlagOverrides", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FlagName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + OverrideType = table.Column(type: "INTEGER", nullable: false), + OverrideValue = table.Column(type: "TEXT", maxLength: 256, nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + ConcurrencyStamp = table.Column(type: "TEXT", maxLength: 40, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeatureFlags_FeatureFlagOverrides", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_FeatureFlags_FeatureFlags_Name", + table: "FeatureFlags_FeatureFlags", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FeatureFlags_FeatureFlagOverrides_FlagName_OverrideType_OverrideValue", + table: "FeatureFlags_FeatureFlagOverrides", + columns: new[] { "FlagName", "OverrideType", "OverrideValue" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FeatureFlags_FeatureFlagOverrides"); + + migrationBuilder.DropTable( + name: "FeatureFlags_FeatureFlags"); + } + } +} diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs index b1fd452f..00dd0116 100644 --- a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -459,6 +459,85 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditLogs_AuditEntries", (string)null); }); + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); + }); + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => { b.Property("Id") @@ -794,61 +873,61 @@ protected override void BuildModel(ModelBuilder modelBuilder) { Id = 1, Name = "Fantastic Rubber Shoes", - Price = 99168m + Price = 991.68m }, new { Id = 2, Name = "Fantastic Rubber Bacon", - Price = 44622m + Price = 446.22m }, new { Id = 3, Name = "Fantastic Concrete Bike", - Price = 66012m + Price = 660.12m }, new { Id = 4, Name = "Handcrafted Concrete Keyboard", - Price = 63367m + Price = 633.67m }, new { Id = 5, Name = "Intelligent Frozen Mouse", - Price = 67430m + Price = 674.30m }, new { Id = 6, Name = "Sleek Soft Hat", - Price = 85163m + Price = 851.63m }, new { Id = 7, Name = "Practical Fresh Bike", - Price = 41748m + Price = 417.48m }, new { Id = 8, Name = "Handmade Steel Ball", - Price = 97556m + Price = 975.56m }, new { Id = 9, Name = "Ergonomic Fresh Pants", - Price = 92809m + Price = 928.09m }, new { Id = 10, Name = "Licensed Steel Sausages", - Price = 59260m + Price = 592.60m }); });