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..83ed3d4f
--- /dev/null
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Middleware/FeatureFlagMiddleware.cs
@@ -0,0 +1,38 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+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)
+ {
+ 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);
+
+ 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/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)}
+ />
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ );
+}
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/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