Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ reviews:
- "!**/*.Designer.cs"
- "!**/bin/**"
- "!**/obj/**"
- "!**/Tests/**"
- "!**/.claude/**"
- "!**/*.md"
path_instructions: []
abort_on_close: true
disable_cache: false
Expand Down Expand Up @@ -230,4 +233,4 @@ issue_enrichment:
labels: []
labeling:
labeling_instructions: []
auto_apply_labels: false
auto_apply_labels: false
41 changes: 41 additions & 0 deletions Core/Resgrid.Config/FeatureFlagsConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Generic;

namespace Resgrid.Config
{
/// <summary>
/// Global configuration for the built-in feature toggle subsystem. Loaded by ConfigProcessor via
/// reflection (keys: "FeatureFlagsConfig.Field" in JSON or "RESGRID:FeatureFlagsConfig:Field" env).
/// </summary>
public static class FeatureFlagsConfig
{
/// <summary>
/// Master switch for the whole subsystem. When false, evaluations short-circuit to the
/// caller-supplied (or code-registered) default and no flag/override data is consulted.
/// </summary>
public static bool FeatureFlagsEnabled = true;

/// <summary>
/// How long the flag set and per-department overrides are cached. Flag/override writes invalidate
/// the relevant cache immediately, so this can be generous.
/// </summary>
public static int CacheDurationMinutes = 60;

/// <summary>
/// When true, evaluations increment in-memory counters that the usage-flush worker persists to
/// FeatureFlagUsages and uses to refresh LastEvaluatedOn (for stale-flag detection).
/// </summary>
public static bool TrackEvaluations = true;

/// <summary>How often the usage-flush worker drains the in-memory evaluation counters.</summary>
public static int EvaluationFlushIntervalSeconds = 60;

/// <summary>Non-permanent flags not evaluated within this many days are reported as stale.</summary>
public static int StaleFlagThresholdDays = 90;

/// <summary>
/// Code-registered boolean defaults keyed by flag key. Used as the fallback when a flag has not
/// yet been seeded in the database, so new flags behave predictably before they exist as rows.
/// </summary>
public static Dictionary<string, bool> CodeDefaults = new Dictionary<string, bool>();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
5 changes: 4 additions & 1 deletion Core/Resgrid.Model/AuditLogTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ public enum AuditLogTypes
WeatherAlertZoneDeleted,
WeatherAlertZoneEnabled,
WeatherAlertZoneDisabled,
WeatherAlertSettingsChanged
WeatherAlertSettingsChanged,
// Feature Toggles
FeatureFlagChanged,
FeatureFlagOverrideChanged
}
}
125 changes: 125 additions & 0 deletions Core/Resgrid.Model/FeatureToggles/FeatureFlag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using ProtoBuf;

namespace Resgrid.Model
{
/// <summary>
/// A system-wide feature flag definition with a global default. Per-department behavior is
/// layered on top via <see cref="FeatureFlagOverride"/>, <see cref="FeatureFlagTargetingRule"/>
/// and <see cref="FeatureFlagPrerequisite"/>.
/// </summary>
[Table("FeatureFlags")]
[ProtoContract]
public class FeatureFlag : IEntity
{
[Key]
[Required]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[ProtoMember(1)]
public int FeatureFlagId { get; set; }

/// <summary>Stable identifier referenced by code and clients (e.g. "new-dispatch-ui").</summary>
[Required]
[ProtoMember(2)]
public string FlagKey { get; set; }

[Required]
[ProtoMember(3)]
public string Name { get; set; }

[ProtoMember(4)]
public string Description { get; set; }

[ProtoMember(5)]
public string Category { get; set; }

/// <summary>Comma-separated free-form tags for grouping/searching.</summary>
[ProtoMember(6)]
public string Tags { get; set; }

/// <summary>Backing int for <see cref="FeatureFlagValueTypes"/>.</summary>
[Required]
[ProtoMember(7)]
public int FlagType { get; set; }

/// <summary>The global on/off default and application-wide kill switch.</summary>
[Required]
[ProtoMember(8)]
public bool IsEnabledGlobally { get; set; }

/// <summary>Value returned when the flag resolves "on" for multivariate flags.</summary>
[ProtoMember(9)]
public string DefaultValue { get; set; }

/// <summary>Value returned when the flag resolves "off" for multivariate flags.</summary>
[ProtoMember(10)]
public string OffValue { get; set; }

/// <summary>0-100 gradual rollout across departments when globally on; null = 100%.</summary>
[ProtoMember(11)]
public int? RolloutPercentage { get; set; }

/// <summary>Optional minimum subscription plan id required for the flag to be on.</summary>
[ProtoMember(12)]
public int? MinimumPlanType { get; set; }

/// <summary>Optional environment scope (backing int for SystemEnvironment); null = all.</summary>
[ProtoMember(13)]
public int? Environment { get; set; }

[ProtoMember(14)]
public DateTime? EnableOn { get; set; }

[ProtoMember(15)]
public DateTime? DisableOn { get; set; }

[Required]
[ProtoMember(16)]
public bool IsArchived { get; set; }

/// <summary>Permanent flags are excluded from stale-flag detection.</summary>
[Required]
[ProtoMember(17)]
public bool IsPermanent { get; set; }

[ProtoMember(18)]
public DateTime? LastEvaluatedOn { get; set; }

[Required]
[ProtoMember(19)]
public DateTime CreatedOn { get; set; }

[ProtoMember(20)]
public string CreatedByUserId { get; set; }

[ProtoMember(21)]
public DateTime? UpdatedOn { get; set; }

[ProtoMember(22)]
public string UpdatedByUserId { get; set; }

[NotMapped]
[JsonIgnore]
public object IdValue
{
get { return FeatureFlagId; }
set { FeatureFlagId = (int)value; }
}

[NotMapped]
public string TableName => "FeatureFlags";

[NotMapped]
public string IdName => "FeatureFlagId";

[NotMapped]
public int IdType => 0;

[NotMapped]
public IEnumerable<string> IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
}
}
24 changes: 24 additions & 0 deletions Core/Resgrid.Model/FeatureToggles/FeatureFlagAttributeTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Resgrid.Model
{
/// <summary>
/// The department attribute a targeting rule compares against. Resolved lazily (and cached) during
/// evaluation from the department, its subscription plan, and personnel counts.
/// </summary>
public enum FeatureFlagAttributeTypes
{
/// <summary>The department's current subscription plan id.</summary>
PlanType = 0,
/// <summary>The department's country/region.</summary>
Country = 1,
/// <summary>The department's active personnel count.</summary>
PersonnelCount = 2,
/// <summary>The department's type (e.g. fire, ems).</summary>
DepartmentType = 3,
/// <summary>The department's creation date.</summary>
CreatedDate = 4,
/// <summary>The department id itself (allow/deny lists).</summary>
DepartmentId = 5,
/// <summary>A caller-supplied custom context value (matched by ComparisonValue key).</summary>
Custom = 6,
}
}
27 changes: 27 additions & 0 deletions Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Resgrid.Model
{
/// <summary>
/// The resolved state of a feature flag for a specific department, including the reason it
/// resolved that way. Returned by the feature toggle service and surfaced by the poll API.
/// </summary>
public class FeatureFlagEvaluation
{
public int FeatureFlagId { get; set; }

/// <summary>The flag's stable key.</summary>
public string Key { get; set; }

public bool IsEnabled { get; set; }

/// <summary>Resolved value (for multivariate flags); for boolean flags mirrors IsEnabled.</summary>
public string Value { get; set; }

public FeatureFlagValueTypes ValueType { get; set; }

/// <summary>Which rule in the evaluation ladder decided the result.</summary>
public FeatureFlagEvaluationSource Source { get; set; }

/// <summary>The targeting rule id when <see cref="Source"/> is TargetingRule.</summary>
public int? MatchedRuleId { get; set; }
}
}
32 changes: 32 additions & 0 deletions Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluationSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Resgrid.Model
{
/// <summary>
/// Explains which rule in the evaluation ladder decided a flag's value for a department. Returned
/// on every evaluation so the poll API and logs can show LaunchDarkly-style "evaluation reasons".
/// </summary>
public enum FeatureFlagEvaluationSource
{
/// <summary>No flag with the requested key exists.</summary>
NotFound = 0,
/// <summary>Value came from a code-registered default in FeatureFlagsConfig.</summary>
CodeDefault = 1,
/// <summary>The whole feature-toggle subsystem is disabled via config.</summary>
SubsystemDisabled = 2,
/// <summary>The flag is archived.</summary>
Archived = 3,
/// <summary>Decided by the flag's scheduled enable/disable window.</summary>
Schedule = 4,
/// <summary>A prerequisite flag was not satisfied.</summary>
Prerequisite = 5,
/// <summary>An explicit per-department override decided the value.</summary>
Override = 6,
/// <summary>The department's subscription plan did not meet the flag's minimum plan.</summary>
PlanGate = 7,
/// <summary>A matching attribute/segment targeting rule decided the value.</summary>
TargetingRule = 8,
/// <summary>Decided by the global default and the percentage rollout bucket.</summary>
GlobalRollout = 9,
/// <summary>Decided by the global default (fully on/off, no rollout in effect).</summary>
GlobalDefault = 10,
}
}
19 changes: 19 additions & 0 deletions Core/Resgrid.Model/FeatureToggles/FeatureFlagOperatorTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Resgrid.Model
{
/// <summary>
/// The comparison applied by a targeting rule between a department attribute and the rule's
/// ComparisonValue. In/NotIn treat ComparisonValue as a comma-separated list.
/// </summary>
public enum FeatureFlagOperatorTypes
{
Equals = 0,
NotEquals = 1,
In = 2,
NotIn = 3,
GreaterThan = 4,
GreaterThanOrEqual = 5,
LessThan = 6,
LessThanOrEqual = 7,
Contains = 8,
}
}
80 changes: 80 additions & 0 deletions Core/Resgrid.Model/FeatureToggles/FeatureFlagOverride.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using ProtoBuf;

namespace Resgrid.Model
{
/// <summary>
/// A per-department override of a feature flag's value. An explicit, non-expired override takes
/// precedence over rollout and targeting rules.
/// </summary>
[Table("FeatureFlagOverrides")]
[ProtoContract]
public class FeatureFlagOverride : IEntity
{
[Key]
[Required]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[ProtoMember(1)]
public int FeatureFlagOverrideId { get; set; }

[Required]
[ProtoMember(2)]
public int FeatureFlagId { get; set; }

[Required]
[ProtoMember(3)]
public int DepartmentId { get; set; }

[Required]
[ProtoMember(4)]
public bool IsEnabled { get; set; }

/// <summary>Override variant value for multivariate flags.</summary>
[ProtoMember(5)]
public string FlagValue { get; set; }

[ProtoMember(6)]
public string Reason { get; set; }

/// <summary>Optional expiry after which the override is ignored.</summary>
[ProtoMember(7)]
public DateTime? ExpiresOn { get; set; }

[Required]
[ProtoMember(8)]
public DateTime CreatedOn { get; set; }

[ProtoMember(9)]
public string CreatedByUserId { get; set; }

[ProtoMember(10)]
public DateTime? UpdatedOn { get; set; }

[ProtoMember(11)]
public string UpdatedByUserId { get; set; }

[NotMapped]
[JsonIgnore]
public object IdValue
{
get { return FeatureFlagOverrideId; }
set { FeatureFlagOverrideId = (int)value; }
}

[NotMapped]
public string TableName => "FeatureFlagOverrides";

[NotMapped]
public string IdName => "FeatureFlagOverrideId";

[NotMapped]
public int IdType => 0;

[NotMapped]
public IEnumerable<string> IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
}
}
Loading
Loading