Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.Concurrent;

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 ConcurrentDictionary<string, bool> CodeDefaults = new ConcurrentDictionary<string, bool>();
}
}
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
}
}
16 changes: 16 additions & 0 deletions Core/Resgrid.Model/FeatureFlagKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Resgrid.Model
{
/// <summary>
/// Well-known feature flag keys consumed by application code. Keys are matched case-insensitively by
/// <see cref="Services.IFeatureToggleService"/>. When adding a key here, seed a matching flag row via a
/// migration (see M0072) so the flag is immediately manageable from the admin UI/API.
/// </summary>
public static class FeatureFlagKeys
{
/// <summary>
/// Routes inbound Twilio SMS through the new chatbot ingress pipeline. When off (globally or for a
/// specific department) the original text-command handling in TwilioController is used instead.
/// </summary>
public const string ChatbotTwilioTextIntegration = "Chatbot.TwilioTextIntegration";
}
}
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