diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index b4f7cf264..2d5c0aeaa 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -37,6 +37,9 @@ reviews:
- "!**/*.Designer.cs"
- "!**/bin/**"
- "!**/obj/**"
+ - "!**/Tests/**"
+ - "!**/.claude/**"
+ - "!**/*.md"
path_instructions: []
abort_on_close: true
disable_cache: false
@@ -230,4 +233,4 @@ issue_enrichment:
labels: []
labeling:
labeling_instructions: []
- auto_apply_labels: false
\ No newline at end of file
+ auto_apply_labels: false
diff --git a/Core/Resgrid.Config/FeatureFlagsConfig.cs b/Core/Resgrid.Config/FeatureFlagsConfig.cs
new file mode 100644
index 000000000..eb23d73ef
--- /dev/null
+++ b/Core/Resgrid.Config/FeatureFlagsConfig.cs
@@ -0,0 +1,41 @@
+using System.Collections.Concurrent;
+
+namespace Resgrid.Config
+{
+ ///
+ /// Global configuration for the built-in feature toggle subsystem. Loaded by ConfigProcessor via
+ /// reflection (keys: "FeatureFlagsConfig.Field" in JSON or "RESGRID:FeatureFlagsConfig:Field" env).
+ ///
+ public static class FeatureFlagsConfig
+ {
+ ///
+ /// 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.
+ ///
+ public static bool FeatureFlagsEnabled = true;
+
+ ///
+ /// How long the flag set and per-department overrides are cached. Flag/override writes invalidate
+ /// the relevant cache immediately, so this can be generous.
+ ///
+ public static int CacheDurationMinutes = 60;
+
+ ///
+ /// When true, evaluations increment in-memory counters that the usage-flush worker persists to
+ /// FeatureFlagUsages and uses to refresh LastEvaluatedOn (for stale-flag detection).
+ ///
+ public static bool TrackEvaluations = true;
+
+ /// How often the usage-flush worker drains the in-memory evaluation counters.
+ public static int EvaluationFlushIntervalSeconds = 60;
+
+ /// Non-permanent flags not evaluated within this many days are reported as stale.
+ public static int StaleFlagThresholdDays = 90;
+
+ ///
+ /// 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.
+ ///
+ public static ConcurrentDictionary CodeDefaults = new ConcurrentDictionary();
+ }
+}
diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs
index be9d1396b..20ee009e3 100644
--- a/Core/Resgrid.Model/AuditLogTypes.cs
+++ b/Core/Resgrid.Model/AuditLogTypes.cs
@@ -160,6 +160,9 @@ public enum AuditLogTypes
WeatherAlertZoneDeleted,
WeatherAlertZoneEnabled,
WeatherAlertZoneDisabled,
- WeatherAlertSettingsChanged
+ WeatherAlertSettingsChanged,
+ // Feature Toggles
+ FeatureFlagChanged,
+ FeatureFlagOverrideChanged
}
}
diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs
new file mode 100644
index 000000000..c4e962f0b
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureFlagKeys.cs
@@ -0,0 +1,16 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Well-known feature flag keys consumed by application code. Keys are matched case-insensitively by
+ /// . 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.
+ ///
+ public static class FeatureFlagKeys
+ {
+ ///
+ /// 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.
+ ///
+ public const string ChatbotTwilioTextIntegration = "Chatbot.TwilioTextIntegration";
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlag.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlag.cs
new file mode 100644
index 000000000..89742d047
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlag.cs
@@ -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
+{
+ ///
+ /// A system-wide feature flag definition with a global default. Per-department behavior is
+ /// layered on top via ,
+ /// and .
+ ///
+ [Table("FeatureFlags")]
+ [ProtoContract]
+ public class FeatureFlag : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int FeatureFlagId { get; set; }
+
+ /// Stable identifier referenced by code and clients (e.g. "new-dispatch-ui").
+ [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; }
+
+ /// Comma-separated free-form tags for grouping/searching.
+ [ProtoMember(6)]
+ public string Tags { get; set; }
+
+ /// Backing int for .
+ [Required]
+ [ProtoMember(7)]
+ public int FlagType { get; set; }
+
+ /// The global on/off default and application-wide kill switch.
+ [Required]
+ [ProtoMember(8)]
+ public bool IsEnabledGlobally { get; set; }
+
+ /// Value returned when the flag resolves "on" for multivariate flags.
+ [ProtoMember(9)]
+ public string DefaultValue { get; set; }
+
+ /// Value returned when the flag resolves "off" for multivariate flags.
+ [ProtoMember(10)]
+ public string OffValue { get; set; }
+
+ /// 0-100 gradual rollout across departments when globally on; null = 100%.
+ [ProtoMember(11)]
+ public int? RolloutPercentage { get; set; }
+
+ /// Optional minimum subscription plan id required for the flag to be on.
+ [ProtoMember(12)]
+ public int? MinimumPlanType { get; set; }
+
+ /// Optional environment scope (backing int for SystemEnvironment); null = all.
+ [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; }
+
+ /// Permanent flags are excluded from stale-flag detection.
+ [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 IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagAttributeTypes.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagAttributeTypes.cs
new file mode 100644
index 000000000..135d477b4
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagAttributeTypes.cs
@@ -0,0 +1,24 @@
+namespace Resgrid.Model
+{
+ ///
+ /// The department attribute a targeting rule compares against. Resolved lazily (and cached) during
+ /// evaluation from the department, its subscription plan, and personnel counts.
+ ///
+ public enum FeatureFlagAttributeTypes
+ {
+ /// The department's current subscription plan id.
+ PlanType = 0,
+ /// The department's country/region.
+ Country = 1,
+ /// The department's active personnel count.
+ PersonnelCount = 2,
+ /// The department's type (e.g. fire, ems).
+ DepartmentType = 3,
+ /// The department's creation date.
+ CreatedDate = 4,
+ /// The department id itself (allow/deny lists).
+ DepartmentId = 5,
+ /// A caller-supplied custom context value (matched by ComparisonValue key).
+ Custom = 6,
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluation.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluation.cs
new file mode 100644
index 000000000..1cf616bf4
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluation.cs
@@ -0,0 +1,27 @@
+namespace Resgrid.Model
+{
+ ///
+ /// 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.
+ ///
+ public class FeatureFlagEvaluation
+ {
+ public int FeatureFlagId { get; set; }
+
+ /// The flag's stable key.
+ public string Key { get; set; }
+
+ public bool IsEnabled { get; set; }
+
+ /// Resolved value (for multivariate flags); for boolean flags mirrors IsEnabled.
+ public string Value { get; set; }
+
+ public FeatureFlagValueTypes ValueType { get; set; }
+
+ /// Which rule in the evaluation ladder decided the result.
+ public FeatureFlagEvaluationSource Source { get; set; }
+
+ /// The targeting rule id when is TargetingRule.
+ public int? MatchedRuleId { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluationSource.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluationSource.cs
new file mode 100644
index 000000000..fce8e9cdb
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagEvaluationSource.cs
@@ -0,0 +1,32 @@
+namespace Resgrid.Model
+{
+ ///
+ /// 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".
+ ///
+ public enum FeatureFlagEvaluationSource
+ {
+ /// No flag with the requested key exists.
+ NotFound = 0,
+ /// Value came from a code-registered default in FeatureFlagsConfig.
+ CodeDefault = 1,
+ /// The whole feature-toggle subsystem is disabled via config.
+ SubsystemDisabled = 2,
+ /// The flag is archived.
+ Archived = 3,
+ /// Decided by the flag's scheduled enable/disable window.
+ Schedule = 4,
+ /// A prerequisite flag was not satisfied.
+ Prerequisite = 5,
+ /// An explicit per-department override decided the value.
+ Override = 6,
+ /// The department's subscription plan did not meet the flag's minimum plan.
+ PlanGate = 7,
+ /// A matching attribute/segment targeting rule decided the value.
+ TargetingRule = 8,
+ /// Decided by the global default and the percentage rollout bucket.
+ GlobalRollout = 9,
+ /// Decided by the global default (fully on/off, no rollout in effect).
+ GlobalDefault = 10,
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagOperatorTypes.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagOperatorTypes.cs
new file mode 100644
index 000000000..41391e05a
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagOperatorTypes.cs
@@ -0,0 +1,19 @@
+namespace Resgrid.Model
+{
+ ///
+ /// 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.
+ ///
+ public enum FeatureFlagOperatorTypes
+ {
+ Equals = 0,
+ NotEquals = 1,
+ In = 2,
+ NotIn = 3,
+ GreaterThan = 4,
+ GreaterThanOrEqual = 5,
+ LessThan = 6,
+ LessThanOrEqual = 7,
+ Contains = 8,
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagOverride.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagOverride.cs
new file mode 100644
index 000000000..ea45905ff
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagOverride.cs
@@ -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
+{
+ ///
+ /// A per-department override of a feature flag's value. An explicit, non-expired override takes
+ /// precedence over rollout and targeting rules.
+ ///
+ [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; }
+
+ /// Override variant value for multivariate flags.
+ [ProtoMember(5)]
+ public string FlagValue { get; set; }
+
+ [ProtoMember(6)]
+ public string Reason { get; set; }
+
+ /// Optional expiry after which the override is ignored.
+ [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 IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagPrerequisite.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagPrerequisite.cs
new file mode 100644
index 000000000..b9c8989d7
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagPrerequisite.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+using ProtoBuf;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A dependency edge: the dependent flag () only resolves "on" for a
+ /// department when the required flag () also does. The graph is
+ /// validated acyclic at write time.
+ ///
+ [Table("FeatureFlagPrerequisites")]
+ [ProtoContract]
+ public class FeatureFlagPrerequisite : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int FeatureFlagPrerequisiteId { get; set; }
+
+ /// The flag that depends on another.
+ [Required]
+ [ProtoMember(2)]
+ public int FeatureFlagId { get; set; }
+
+ /// The flag that must be satisfied first.
+ [Required]
+ [ProtoMember(3)]
+ public int RequiredFeatureFlagId { get; set; }
+
+ /// Optional required variant value; null means "the required flag must be enabled".
+ [ProtoMember(4)]
+ public string RequiredValue { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return FeatureFlagPrerequisiteId; }
+ set { FeatureFlagPrerequisiteId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "FeatureFlagPrerequisites";
+
+ [NotMapped]
+ public string IdName => "FeatureFlagPrerequisiteId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagTargetingRule.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagTargetingRule.cs
new file mode 100644
index 000000000..b4d10abd1
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagTargetingRule.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+using ProtoBuf;
+
+namespace Resgrid.Model
+{
+ ///
+ /// An attribute/segment targeting rule for a feature flag. Rules are evaluated in ascending
+ /// order; the first matching rule decides the value (after overrides and
+ /// the optional plan gate, but before the global default/rollout).
+ ///
+ [Table("FeatureFlagTargetingRules")]
+ [ProtoContract]
+ public class FeatureFlagTargetingRule : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int FeatureFlagTargetingRuleId { get; set; }
+
+ [Required]
+ [ProtoMember(2)]
+ public int FeatureFlagId { get; set; }
+
+ /// Lower numbers are evaluated first.
+ [Required]
+ [ProtoMember(3)]
+ public int Priority { get; set; }
+
+ /// Backing int for .
+ [Required]
+ [ProtoMember(4)]
+ public int AttributeType { get; set; }
+
+ /// Backing int for .
+ [Required]
+ [ProtoMember(5)]
+ public int OperatorType { get; set; }
+
+ /// The value (or comma-separated list for In/NotIn) compared against the attribute.
+ /// For the Custom attribute the leading "key:" segment selects the context key.
+ [ProtoMember(6)]
+ public string ComparisonValue { get; set; }
+
+ /// Whether a matching department is on or off.
+ [Required]
+ [ProtoMember(7)]
+ public bool ResultEnabled { get; set; }
+
+ /// Variant value returned for a matching department on multivariate flags.
+ [ProtoMember(8)]
+ public string ResultValue { get; set; }
+
+ /// Optional 0-100 rollout within the matched segment.
+ [ProtoMember(9)]
+ public int? RolloutPercentage { get; set; }
+
+ [Required]
+ [ProtoMember(10)]
+ public DateTime CreatedOn { get; set; }
+
+ [ProtoMember(11)]
+ public string CreatedByUserId { get; set; }
+
+ [ProtoMember(12)]
+ public DateTime? UpdatedOn { get; set; }
+
+ [ProtoMember(13)]
+ public string UpdatedByUserId { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return FeatureFlagTargetingRuleId; }
+ set { FeatureFlagTargetingRuleId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "FeatureFlagTargetingRules";
+
+ [NotMapped]
+ public string IdName => "FeatureFlagTargetingRuleId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagUsage.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagUsage.cs
new file mode 100644
index 000000000..769585fcf
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagUsage.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+using ProtoBuf;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Aggregated daily evaluation counts for a feature flag (optionally per department). Written by a
+ /// background worker that flushes the service's in-memory counters; never written on the hot path.
+ /// Mirrors the WorkflowDailyUsages aggregation pattern.
+ ///
+ [Table("FeatureFlagUsages")]
+ [ProtoContract]
+ public class FeatureFlagUsage : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int FeatureFlagUsageId { get; set; }
+
+ [Required]
+ [ProtoMember(2)]
+ public int FeatureFlagId { get; set; }
+
+ /// Null aggregates across all departments for the day.
+ [ProtoMember(3)]
+ public int? DepartmentId { get; set; }
+
+ [Required]
+ [ProtoMember(4)]
+ public DateTime UsageDate { get; set; }
+
+ [Required]
+ [ProtoMember(5)]
+ public long EvaluationCount { get; set; }
+
+ [Required]
+ [ProtoMember(6)]
+ public long EnabledCount { get; set; }
+
+ [Required]
+ [ProtoMember(7)]
+ public long DisabledCount { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return FeatureFlagUsageId; }
+ set { FeatureFlagUsageId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "FeatureFlagUsages";
+
+ [NotMapped]
+ public string IdName => "FeatureFlagUsageId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureToggles/FeatureFlagValueTypes.cs b/Core/Resgrid.Model/FeatureToggles/FeatureFlagValueTypes.cs
new file mode 100644
index 000000000..508244f34
--- /dev/null
+++ b/Core/Resgrid.Model/FeatureToggles/FeatureFlagValueTypes.cs
@@ -0,0 +1,14 @@
+namespace Resgrid.Model
+{
+ ///
+ /// The type of value a feature flag resolves to. Boolean is a simple on/off toggle; the others
+ /// support multivariate flags where a string/number/JSON payload is returned to the caller.
+ ///
+ public enum FeatureFlagValueTypes
+ {
+ Boolean = 0,
+ String = 1,
+ Number = 2,
+ Json = 3,
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IFeatureFlagOverrideRepository.cs b/Core/Resgrid.Model/Repositories/IFeatureFlagOverrideRepository.cs
new file mode 100644
index 000000000..b5803aef1
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IFeatureFlagOverrideRepository.cs
@@ -0,0 +1,10 @@
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Repository for per-department feature flag overrides. Per-department reads use the generic
+ /// .
+ ///
+ public interface IFeatureFlagOverrideRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IFeatureFlagPrerequisiteRepository.cs b/Core/Resgrid.Model/Repositories/IFeatureFlagPrerequisiteRepository.cs
new file mode 100644
index 000000000..a0cf0a08c
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IFeatureFlagPrerequisiteRepository.cs
@@ -0,0 +1,10 @@
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Repository for feature flag prerequisite (dependency) edges. Reads use the generic
+ /// (cached alongside the flag set).
+ ///
+ public interface IFeatureFlagPrerequisiteRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs b/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs
new file mode 100644
index 000000000..9554b95b5
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs
@@ -0,0 +1,10 @@
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Repository for system-wide feature flag definitions. The flag set is small and fully cached,
+ /// so reads use the generic and callers filter in memory.
+ ///
+ public interface IFeatureFlagRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IFeatureFlagTargetingRuleRepository.cs b/Core/Resgrid.Model/Repositories/IFeatureFlagTargetingRuleRepository.cs
new file mode 100644
index 000000000..1f7430178
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IFeatureFlagTargetingRuleRepository.cs
@@ -0,0 +1,10 @@
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Repository for feature flag attribute/segment targeting rules. Reads use the generic
+ /// (cached alongside the flag set).
+ ///
+ public interface IFeatureFlagTargetingRuleRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IFeatureFlagUsageRepository.cs b/Core/Resgrid.Model/Repositories/IFeatureFlagUsageRepository.cs
new file mode 100644
index 000000000..8bfd199d9
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IFeatureFlagUsageRepository.cs
@@ -0,0 +1,10 @@
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Repository for aggregated daily feature flag evaluation counts. Written by the usage-flush
+ /// worker via the generic insert/update; analytics reads use .
+ ///
+ public interface IFeatureFlagUsageRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IFeatureToggleService.cs b/Core/Resgrid.Model/Services/IFeatureToggleService.cs
new file mode 100644
index 000000000..078b278ed
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IFeatureToggleService.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Built-in feature toggle service. Resolves a flag's value for a department through a layered
+ /// evaluation ladder (subsystem switch, archive, environment, schedule, prerequisites, per-department
+ /// override, optional plan gate, targeting rules, then global default + percentage rollout) and
+ /// provides flag/override/targeting/prerequisite management plus evaluation analytics. All reads on
+ /// the evaluation hot path are served from cache.
+ ///
+ public interface IFeatureToggleService
+ {
+ #region Evaluation (hot path)
+
+ /// Returns whether a flag is enabled for a department, falling back to defaultValue when unknown.
+ Task IsEnabledAsync(string key, int departmentId, bool defaultValue = false, IDictionary context = null);
+
+ /// Full evaluation including the value and the reason (source) it resolved that way.
+ Task EvaluateAsync(string key, int departmentId, IDictionary context = null);
+
+ /// Resolved string value for a multivariate flag (or defaultValue when off/unknown).
+ Task GetValueAsync(string key, int departmentId, string defaultValue = null, IDictionary context = null);
+
+ /// Evaluates every active flag for a department — used by the bulk poll API.
+ Task> EvaluateAllForDepartmentAsync(int departmentId, IDictionary context = null);
+
+ /// A stable hash of a department's full resolved flag state, for ETag/304 polling.
+ Task GetDepartmentFlagStateHashAsync(int departmentId);
+
+ #endregion
+
+ #region Flag management (SystemAdmin)
+
+ Task> GetAllFlagsAsync(bool includeArchived = false, bool bypassCache = false);
+
+ Task GetFlagByKeyAsync(string key, bool bypassCache = false);
+
+ /// Creates or updates a flag definition (matched by FlagKey). Audited.
+ Task SaveFlagAsync(FeatureFlag flag, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task ArchiveFlagAsync(string key, string userId, bool archived = true, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task SetGlobalEnabledAsync(string key, bool enabled, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task SetRolloutPercentageAsync(string key, int percentage, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task DeleteFlagAsync(string key, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ #endregion
+
+ #region Override management (SystemAdmin or department admin for own department)
+
+ Task> GetOverridesForFlagAsync(string key);
+
+ Task> GetOverridesForDepartmentAsync(int departmentId, bool bypassCache = false);
+
+ /// Creates or updates a department's override for a flag. Audited against the department.
+ Task SetDepartmentOverrideAsync(string key, int departmentId, bool isEnabled, string value, string reason, DateTime? expiresOn, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task RemoveDepartmentOverrideAsync(string key, int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ #endregion
+
+ #region Targeting rules & prerequisites (SystemAdmin)
+
+ Task> GetTargetingRulesForFlagAsync(string key);
+
+ Task SaveTargetingRuleAsync(FeatureFlagTargetingRule rule, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task RemoveTargetingRuleAsync(int targetingRuleId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task> GetPrerequisitesForFlagAsync(string key);
+
+ /// Adds a prerequisite edge. Throws if it would introduce a cycle.
+ Task AddPrerequisiteAsync(string key, string requiredKey, string requiredValue, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task RemovePrerequisiteAsync(int prerequisiteId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ #endregion
+
+ #region Analytics & lifecycle
+
+ /// Persists buffered evaluation counts and refreshes LastEvaluatedOn. Called by the worker.
+ Task FlushEvaluationsAsync(CancellationToken cancellationToken = default(CancellationToken));
+
+ /// Aggregated daily usage rows for a flag within a date range (admin analytics).
+ Task> GetUsageForFlagAsync(string key, DateTime from, DateTime to);
+
+ /// Non-permanent flags whose LastEvaluatedOn is older than the threshold (or never evaluated).
+ Task> GetStaleFlagsAsync(int? olderThanDays = null);
+
+ #endregion
+
+ #region Cache invalidation
+
+ Task InvalidateFlagCacheAsync();
+
+ Task InvalidateDepartmentOverrideCacheAsync(int departmentId);
+
+ #endregion
+ }
+}
diff --git a/Core/Resgrid.Services/FeatureToggleService.cs b/Core/Resgrid.Services/FeatureToggleService.cs
new file mode 100644
index 000000000..791352900
--- /dev/null
+++ b/Core/Resgrid.Services/FeatureToggleService.cs
@@ -0,0 +1,915 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Built-in feature toggle service. See for the contract. The
+ /// evaluation hot path is served entirely from cache; management writes invalidate the relevant
+ /// cache entries and emit audit events.
+ ///
+ public class FeatureToggleService : IFeatureToggleService
+ {
+ private const string AllFlagsCacheKey = "FeatureFlags_All";
+ private const string AllRulesCacheKey = "FeatureFlagTargetingRules_All";
+ private const string AllPrereqsCacheKey = "FeatureFlagPrereqs_All";
+ private const string DepartmentOverridesCacheKey = "FeatureFlagOverrides_{0}";
+
+ // Static so the in-memory evaluation counters survive across (per-scope) service instances.
+ private static readonly ConcurrentDictionary _usageBuffer = new ConcurrentDictionary();
+
+ private readonly IFeatureFlagRepository _featureFlagRepository;
+ private readonly IFeatureFlagOverrideRepository _featureFlagOverrideRepository;
+ private readonly IFeatureFlagTargetingRuleRepository _featureFlagTargetingRuleRepository;
+ private readonly IFeatureFlagPrerequisiteRepository _featureFlagPrerequisiteRepository;
+ private readonly IFeatureFlagUsageRepository _featureFlagUsageRepository;
+ private readonly ICacheProvider _cacheProvider;
+ private readonly IEventAggregator _eventAggregator;
+ private readonly ISubscriptionsService _subscriptionsService;
+ private readonly IDepartmentsService _departmentsService;
+
+ public FeatureToggleService(IFeatureFlagRepository featureFlagRepository, IFeatureFlagOverrideRepository featureFlagOverrideRepository,
+ IFeatureFlagTargetingRuleRepository featureFlagTargetingRuleRepository, IFeatureFlagPrerequisiteRepository featureFlagPrerequisiteRepository,
+ IFeatureFlagUsageRepository featureFlagUsageRepository, ICacheProvider cacheProvider, IEventAggregator eventAggregator,
+ ISubscriptionsService subscriptionsService, IDepartmentsService departmentsService)
+ {
+ _featureFlagRepository = featureFlagRepository;
+ _featureFlagOverrideRepository = featureFlagOverrideRepository;
+ _featureFlagTargetingRuleRepository = featureFlagTargetingRuleRepository;
+ _featureFlagPrerequisiteRepository = featureFlagPrerequisiteRepository;
+ _featureFlagUsageRepository = featureFlagUsageRepository;
+ _cacheProvider = cacheProvider;
+ _eventAggregator = eventAggregator;
+ _subscriptionsService = subscriptionsService;
+ _departmentsService = departmentsService;
+ }
+
+ private static TimeSpan CacheLength => TimeSpan.FromMinutes(FeatureFlagsConfig.CacheDurationMinutes <= 0 ? 60 : FeatureFlagsConfig.CacheDurationMinutes);
+
+ #region Evaluation (hot path)
+
+ public async Task IsEnabledAsync(string key, int departmentId, bool defaultValue = false, IDictionary context = null)
+ {
+ var evaluation = await EvaluateInternalAsync(key, departmentId, context, defaultValue, new HashSet());
+ return evaluation.IsEnabled;
+ }
+
+ public async Task EvaluateAsync(string key, int departmentId, IDictionary context = null)
+ {
+ return await EvaluateInternalAsync(key, departmentId, context, false, new HashSet());
+ }
+
+ public async Task GetValueAsync(string key, int departmentId, string defaultValue = null, IDictionary context = null)
+ {
+ var evaluation = await EvaluateInternalAsync(key, departmentId, context, false, new HashSet());
+ if (evaluation.Source == FeatureFlagEvaluationSource.NotFound)
+ return defaultValue;
+
+ return evaluation.Value ?? defaultValue;
+ }
+
+ public async Task> EvaluateAllForDepartmentAsync(int departmentId, IDictionary context = null)
+ {
+ var results = new List();
+ var flags = await GetAllFlagsAsync(includeArchived: false);
+
+ foreach (var flag in flags)
+ {
+ results.Add(await EvaluateInternalAsync(flag.FlagKey, departmentId, context, false, new HashSet()));
+ }
+
+ return results;
+ }
+
+ public async Task GetDepartmentFlagStateHashAsync(int departmentId)
+ {
+ var evaluations = await EvaluateAllForDepartmentAsync(departmentId);
+ return ComputeStateHash(evaluations);
+ }
+
+ private async Task EvaluateInternalAsync(string key, int departmentId, IDictionary context, bool defaultValue, HashSet visited)
+ {
+ // 0) Subsystem master switch.
+ if (!FeatureFlagsConfig.FeatureFlagsEnabled)
+ return BuildDefault(key, defaultValue, FeatureFlagEvaluationSource.SubsystemDisabled);
+
+ var flags = await GetAllFlagsAsync(includeArchived: true);
+ var flag = flags.FirstOrDefault(f => string.Equals(f.FlagKey, key, StringComparison.OrdinalIgnoreCase));
+
+ // 1) Unknown flag -> caller/code default.
+ if (flag == null)
+ return BuildDefault(key, defaultValue, FeatureFlagEvaluationSource.NotFound);
+
+ // Guard against prerequisite cycles (the graph is validated acyclic at write time, but be safe).
+ if (!visited.Add(flag.FeatureFlagId))
+ return Build(flag, false, null, FeatureFlagEvaluationSource.Prerequisite);
+
+ FeatureFlagEvaluation evaluation = null;
+ try
+ {
+ // 2) Archived.
+ if (flag.IsArchived)
+ return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.Archived);
+
+ // 3) Environment scope.
+ if (flag.Environment.HasValue && flag.Environment.Value != (int)SystemBehaviorConfig.Environment)
+ return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.GlobalDefault);
+
+ // 4) Scheduling window.
+ var now = DateTime.UtcNow;
+ if ((flag.EnableOn.HasValue && now < flag.EnableOn.Value) || (flag.DisableOn.HasValue && now >= flag.DisableOn.Value))
+ return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.Schedule);
+
+ // 5) Prerequisites.
+ var prerequisites = (await GetAllPrerequisitesAsync()).Where(p => p.FeatureFlagId == flag.FeatureFlagId).ToList();
+ foreach (var prerequisite in prerequisites)
+ {
+ var required = flags.FirstOrDefault(f => f.FeatureFlagId == prerequisite.RequiredFeatureFlagId);
+ if (required == null)
+ continue;
+
+ var requiredEvaluation = await EvaluateInternalAsync(required.FlagKey, departmentId, context, false, visited);
+ var satisfied = string.IsNullOrEmpty(prerequisite.RequiredValue)
+ ? requiredEvaluation.IsEnabled
+ : requiredEvaluation.IsEnabled && string.Equals(requiredEvaluation.Value, prerequisite.RequiredValue, StringComparison.OrdinalIgnoreCase);
+
+ if (!satisfied)
+ return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.Prerequisite);
+ }
+
+ // 6) Per-department override (explicit, non-expired) wins over rollout/targeting.
+ var overrides = await GetOverridesForDepartmentAsync(departmentId);
+ var departmentOverride = overrides.FirstOrDefault(o => o.FeatureFlagId == flag.FeatureFlagId);
+ if (departmentOverride != null && (!departmentOverride.ExpiresOn.HasValue || departmentOverride.ExpiresOn.Value > now))
+ return evaluation = Build(flag, departmentOverride.IsEnabled, departmentOverride.FlagValue, FeatureFlagEvaluationSource.Override);
+
+ // 7) Optional plan gate.
+ if (flag.MinimumPlanType.HasValue)
+ {
+ var gatePassed = await PassesPlanGateAsync(flag.MinimumPlanType.Value, departmentId);
+ if (!gatePassed)
+ return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.PlanGate);
+ }
+
+ // 8) Targeting rules (first match by priority).
+ var rules = (await GetAllTargetingRulesAsync()).Where(r => r.FeatureFlagId == flag.FeatureFlagId).OrderBy(r => r.Priority).ToList();
+ foreach (var rule in rules)
+ {
+ if (!await RuleMatchesAsync(rule, departmentId, context))
+ continue;
+
+ // Optional rollout within the matched segment.
+ if (rule.RolloutPercentage.HasValue && rule.RolloutPercentage.Value < 100)
+ {
+ if (rule.RolloutPercentage.Value <= 0)
+ continue;
+ if (StableBucket(flag.FlagKey + ":" + departmentId + ":rule" + rule.FeatureFlagTargetingRuleId) >= rule.RolloutPercentage.Value)
+ continue;
+ }
+
+ return evaluation = Build(flag, rule.ResultEnabled, rule.ResultValue, FeatureFlagEvaluationSource.TargetingRule, rule.FeatureFlagTargetingRuleId);
+ }
+
+ // 9) Global default + percentage rollout.
+ if (!flag.IsEnabledGlobally)
+ return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.GlobalDefault);
+
+ if (flag.RolloutPercentage.HasValue && flag.RolloutPercentage.Value < 100)
+ {
+ var enabled = flag.RolloutPercentage.Value > 0 && StableBucket(flag.FlagKey + ":" + departmentId) < flag.RolloutPercentage.Value;
+ return evaluation = Build(flag, enabled, enabled ? flag.DefaultValue : flag.OffValue, FeatureFlagEvaluationSource.GlobalRollout);
+ }
+
+ return evaluation = Build(flag, true, flag.DefaultValue, FeatureFlagEvaluationSource.GlobalDefault);
+ }
+ finally
+ {
+ visited.Remove(flag.FeatureFlagId);
+ RecordEvaluation(flag.FeatureFlagId, departmentId, evaluation?.IsEnabled);
+ }
+ }
+
+ private async Task PassesPlanGateAsync(int minimumPlanType, int departmentId)
+ {
+ try
+ {
+ var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId, false);
+
+ // When billing is not configured (no plan), do not gate - the flag behaves as a normal toggle.
+ if (plan == null)
+ return true;
+
+ return plan.PlanId >= minimumPlanType;
+ }
+ catch (Exception ex)
+ {
+ // Don't let a billing outage break flag evaluation; log and treat the gate as open.
+ Logging.LogException(ex, $"FeatureToggle plan gate check failed for department {departmentId}");
+ return true;
+ }
+ }
+
+ private async Task RuleMatchesAsync(FeatureFlagTargetingRule rule, int departmentId, IDictionary context)
+ {
+ var attribute = (FeatureFlagAttributeTypes)rule.AttributeType;
+ var op = (FeatureFlagOperatorTypes)rule.OperatorType;
+
+ string attributeValue;
+ string compareValue = rule.ComparisonValue;
+
+ if (attribute == FeatureFlagAttributeTypes.Custom)
+ {
+ // ComparisonValue is "contextKey:expectedValue".
+ var raw = rule.ComparisonValue ?? string.Empty;
+ var idx = raw.IndexOf(':');
+ var contextKey = idx >= 0 ? raw.Substring(0, idx) : raw;
+ compareValue = idx >= 0 ? raw.Substring(idx + 1) : string.Empty;
+ attributeValue = (context != null && context.TryGetValue(contextKey, out var cv)) ? cv : null;
+ }
+ else
+ {
+ attributeValue = await ResolveAttributeAsync(attribute, departmentId);
+ }
+
+ return MatchOperator(op, attributeValue, compareValue);
+ }
+
+ private async Task ResolveAttributeAsync(FeatureFlagAttributeTypes attribute, int departmentId)
+ {
+ switch (attribute)
+ {
+ case FeatureFlagAttributeTypes.DepartmentId:
+ return departmentId.ToString(CultureInfo.InvariantCulture);
+ case FeatureFlagAttributeTypes.PlanType:
+ {
+ var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId, false);
+ return plan?.PlanId.ToString(CultureInfo.InvariantCulture);
+ }
+ case FeatureFlagAttributeTypes.DepartmentType:
+ {
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
+ return department?.DepartmentType;
+ }
+ case FeatureFlagAttributeTypes.Country:
+ {
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
+ return department?.Address?.Country;
+ }
+ case FeatureFlagAttributeTypes.CreatedDate:
+ {
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
+ return department?.CreatedOn?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ }
+ case FeatureFlagAttributeTypes.PersonnelCount:
+ {
+ var counts = await _subscriptionsService.GetPlanCountsForDepartmentAsync(departmentId);
+ return counts?.UsersCount.ToString(CultureInfo.InvariantCulture);
+ }
+ default:
+ return null;
+ }
+ }
+
+ private static bool MatchOperator(FeatureFlagOperatorTypes op, string attributeValue, string compareValue)
+ {
+ if (attributeValue == null)
+ return op == FeatureFlagOperatorTypes.NotEquals || op == FeatureFlagOperatorTypes.NotIn;
+
+ switch (op)
+ {
+ case FeatureFlagOperatorTypes.Equals:
+ return string.Equals(attributeValue, compareValue, StringComparison.OrdinalIgnoreCase);
+ case FeatureFlagOperatorTypes.NotEquals:
+ return !string.Equals(attributeValue, compareValue, StringComparison.OrdinalIgnoreCase);
+ case FeatureFlagOperatorTypes.In:
+ return SplitList(compareValue).Contains(attributeValue, StringComparer.OrdinalIgnoreCase);
+ case FeatureFlagOperatorTypes.NotIn:
+ return !SplitList(compareValue).Contains(attributeValue, StringComparer.OrdinalIgnoreCase);
+ case FeatureFlagOperatorTypes.Contains:
+ return attributeValue.IndexOf(compareValue ?? string.Empty, StringComparison.OrdinalIgnoreCase) >= 0;
+ case FeatureFlagOperatorTypes.GreaterThan:
+ case FeatureFlagOperatorTypes.GreaterThanOrEqual:
+ case FeatureFlagOperatorTypes.LessThan:
+ case FeatureFlagOperatorTypes.LessThanOrEqual:
+ {
+ if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out var a)
+ && double.TryParse(compareValue, NumberStyles.Any, CultureInfo.InvariantCulture, out var b))
+ {
+ if (op == FeatureFlagOperatorTypes.GreaterThan) return a > b;
+ if (op == FeatureFlagOperatorTypes.GreaterThanOrEqual) return a >= b;
+ if (op == FeatureFlagOperatorTypes.LessThan) return a < b;
+ return a <= b;
+ }
+ return false;
+ }
+ default:
+ return false;
+ }
+ }
+
+ private static IEnumerable SplitList(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return Enumerable.Empty();
+
+ return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(v => v.Trim());
+ }
+
+ private FeatureFlagEvaluation Build(FeatureFlag flag, bool enabled, string explicitValue, FeatureFlagEvaluationSource source, int? matchedRuleId = null)
+ {
+ var value = explicitValue;
+ if (value == null)
+ value = enabled ? (flag.DefaultValue ?? "true") : (flag.OffValue ?? "false");
+
+ return new FeatureFlagEvaluation
+ {
+ FeatureFlagId = flag.FeatureFlagId,
+ Key = flag.FlagKey,
+ IsEnabled = enabled,
+ Value = value,
+ ValueType = (FeatureFlagValueTypes)flag.FlagType,
+ Source = source,
+ MatchedRuleId = matchedRuleId
+ };
+ }
+
+ private FeatureFlagEvaluation BuildDefault(string key, bool defaultValue, FeatureFlagEvaluationSource source)
+ {
+ var enabled = defaultValue;
+ if (FeatureFlagsConfig.CodeDefaults != null && FeatureFlagsConfig.CodeDefaults.TryGetValue(key, out var codeDefault))
+ {
+ enabled = codeDefault;
+ if (source == FeatureFlagEvaluationSource.NotFound)
+ source = FeatureFlagEvaluationSource.CodeDefault;
+ }
+
+ return new FeatureFlagEvaluation
+ {
+ FeatureFlagId = 0,
+ Key = key,
+ IsEnabled = enabled,
+ Value = enabled ? "true" : "false",
+ ValueType = FeatureFlagValueTypes.Boolean,
+ Source = source
+ };
+ }
+
+ private static int StableBucket(string input)
+ {
+ // FNV-1a 32-bit -> 0..99. Deterministic across processes (unlike String.GetHashCode).
+ unchecked
+ {
+ uint hash = 2166136261;
+ foreach (var c in input)
+ {
+ hash ^= c;
+ hash *= 16777619;
+ }
+ return (int)(hash % 100u);
+ }
+ }
+
+ private static string ComputeStateHash(IEnumerable evaluations)
+ {
+ var builder = new StringBuilder();
+ foreach (var evaluation in evaluations.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ builder.Append(evaluation.Key).Append('=').Append(evaluation.IsEnabled ? '1' : '0').Append(':').Append(evaluation.Value).Append(';');
+ }
+
+ using (var sha = SHA256.Create())
+ {
+ var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
+ var hex = new StringBuilder(bytes.Length * 2);
+ foreach (var b in bytes)
+ hex.Append(b.ToString("x2"));
+ return hex.ToString();
+ }
+ }
+
+ #endregion
+
+ #region Cached reads
+
+ public async Task> GetAllFlagsAsync(bool includeArchived = false, bool bypassCache = false)
+ {
+ async Task> fetch()
+ {
+ var all = await _featureFlagRepository.GetAllAsync();
+ return all?.ToList() ?? new List();
+ }
+
+ List flags;
+ if (!bypassCache && SystemBehaviorConfig.CacheEnabled)
+ flags = await _cacheProvider.RetrieveAsync(AllFlagsCacheKey, fetch, CacheLength);
+ else
+ flags = await fetch();
+
+ flags = flags ?? new List();
+ return includeArchived ? flags : flags.Where(f => !f.IsArchived).ToList();
+ }
+
+ public async Task GetFlagByKeyAsync(string key, bool bypassCache = false)
+ {
+ var flags = await GetAllFlagsAsync(includeArchived: true, bypassCache: bypassCache);
+ return flags.FirstOrDefault(f => string.Equals(f.FlagKey, key, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task> GetAllTargetingRulesAsync()
+ {
+ async Task> fetch()
+ {
+ var all = await _featureFlagTargetingRuleRepository.GetAllAsync();
+ return all?.ToList() ?? new List();
+ }
+
+ if (SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(AllRulesCacheKey, fetch, CacheLength) ?? new List();
+
+ return await fetch();
+ }
+
+ private async Task> GetAllPrerequisitesAsync()
+ {
+ async Task> fetch()
+ {
+ var all = await _featureFlagPrerequisiteRepository.GetAllAsync();
+ return all?.ToList() ?? new List();
+ }
+
+ if (SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(AllPrereqsCacheKey, fetch, CacheLength) ?? new List();
+
+ return await fetch();
+ }
+
+ public async Task> GetOverridesForDepartmentAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task> fetch()
+ {
+ var all = await _featureFlagOverrideRepository.GetAllByDepartmentIdAsync(departmentId);
+ return all?.ToList() ?? new List();
+ }
+
+ if (!bypassCache && SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(string.Format(DepartmentOverridesCacheKey, departmentId), fetch, CacheLength) ?? new List();
+
+ return await fetch();
+ }
+
+ #endregion
+
+ #region Flag management
+
+ public async Task SaveFlagAsync(FeatureFlag flag, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (flag == null)
+ throw new ArgumentNullException(nameof(flag));
+ if (string.IsNullOrWhiteSpace(flag.FlagKey))
+ throw new ArgumentException("FlagKey is required.", nameof(flag));
+
+ var existingByKey = await GetFlagByKeyAsync(flag.FlagKey, bypassCache: true);
+ if (existingByKey != null && existingByKey.FeatureFlagId != flag.FeatureFlagId)
+ throw new InvalidOperationException($"A feature flag with key '{flag.FlagKey}' already exists.");
+
+ string before = null;
+ if (flag.FeatureFlagId == 0)
+ {
+ flag.CreatedOn = DateTime.UtcNow;
+ flag.CreatedByUserId = userId;
+ }
+ else
+ {
+ before = existingByKey?.CloneJsonToString();
+ flag.UpdatedOn = DateTime.UtcNow;
+ flag.UpdatedByUserId = userId;
+ if (flag.CreatedOn == default(DateTime) && existingByKey != null)
+ flag.CreatedOn = existingByKey.CreatedOn;
+ }
+
+ var saved = await _featureFlagRepository.SaveOrUpdateAsync(flag, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, saved);
+
+ return saved;
+ }
+
+ public async Task ArchiveFlagAsync(string key, string userId, bool archived = true, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ if (flag == null)
+ return false;
+
+ var before = flag.CloneJsonToString();
+ flag.IsArchived = archived;
+ flag.UpdatedOn = DateTime.UtcNow;
+ flag.UpdatedByUserId = userId;
+
+ await _featureFlagRepository.SaveOrUpdateAsync(flag, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, flag);
+
+ return true;
+ }
+
+ public async Task SetGlobalEnabledAsync(string key, bool enabled, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ if (flag == null)
+ return null;
+
+ var before = flag.CloneJsonToString();
+ flag.IsEnabledGlobally = enabled;
+ flag.UpdatedOn = DateTime.UtcNow;
+ flag.UpdatedByUserId = userId;
+
+ var saved = await _featureFlagRepository.SaveOrUpdateAsync(flag, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, saved);
+
+ return saved;
+ }
+
+ public async Task SetRolloutPercentageAsync(string key, int percentage, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ if (flag == null)
+ return null;
+
+ var before = flag.CloneJsonToString();
+ flag.RolloutPercentage = Math.Max(0, Math.Min(100, percentage));
+ flag.UpdatedOn = DateTime.UtcNow;
+ flag.UpdatedByUserId = userId;
+
+ var saved = await _featureFlagRepository.SaveOrUpdateAsync(flag, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, saved);
+
+ return saved;
+ }
+
+ public async Task DeleteFlagAsync(string key, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ if (flag == null)
+ return false;
+
+ var before = flag.CloneJsonToString();
+
+ var rules = (await _featureFlagTargetingRuleRepository.GetAllAsync())?.Where(r => r.FeatureFlagId == flag.FeatureFlagId) ?? Enumerable.Empty();
+ foreach (var rule in rules.ToList())
+ await _featureFlagTargetingRuleRepository.DeleteAsync(rule, cancellationToken);
+
+ var prereqs = (await _featureFlagPrerequisiteRepository.GetAllAsync())?.Where(p => p.FeatureFlagId == flag.FeatureFlagId || p.RequiredFeatureFlagId == flag.FeatureFlagId) ?? Enumerable.Empty();
+ foreach (var prereq in prereqs.ToList())
+ await _featureFlagPrerequisiteRepository.DeleteAsync(prereq, cancellationToken);
+
+ var overrides = (await _featureFlagOverrideRepository.GetAllAsync())?.Where(o => o.FeatureFlagId == flag.FeatureFlagId) ?? Enumerable.Empty();
+ foreach (var ovr in overrides.ToList())
+ await _featureFlagOverrideRepository.DeleteAsync(ovr, cancellationToken);
+
+ var usages = (await _featureFlagUsageRepository.GetAllAsync())?.Where(u => u.FeatureFlagId == flag.FeatureFlagId) ?? Enumerable.Empty();
+ foreach (var usage in usages.ToList())
+ await _featureFlagUsageRepository.DeleteAsync(usage, cancellationToken);
+
+ await _featureFlagRepository.DeleteAsync(flag, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, null);
+
+ return true;
+ }
+
+ #endregion
+
+ #region Override management
+
+ public async Task> GetOverridesForFlagAsync(string key)
+ {
+ var flag = await GetFlagByKeyAsync(key);
+ if (flag == null)
+ return new List();
+
+ var all = await _featureFlagOverrideRepository.GetAllAsync();
+ return all?.Where(o => o.FeatureFlagId == flag.FeatureFlagId).ToList() ?? new List();
+ }
+
+ public async Task SetDepartmentOverrideAsync(string key, int departmentId, bool isEnabled, string value, string reason, DateTime? expiresOn, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ if (flag == null)
+ throw new InvalidOperationException($"No feature flag exists with key '{key}'.");
+
+ var existing = (await GetOverridesForDepartmentAsync(departmentId, bypassCache: true)).FirstOrDefault(o => o.FeatureFlagId == flag.FeatureFlagId);
+ string before = null;
+
+ var ovr = existing ?? new FeatureFlagOverride
+ {
+ FeatureFlagId = flag.FeatureFlagId,
+ DepartmentId = departmentId,
+ CreatedOn = DateTime.UtcNow,
+ CreatedByUserId = userId
+ };
+
+ if (existing != null)
+ {
+ before = existing?.CloneJsonToString();
+ ovr.UpdatedOn = DateTime.UtcNow;
+ ovr.UpdatedByUserId = userId;
+ }
+
+ ovr.IsEnabled = isEnabled;
+ ovr.FlagValue = value;
+ ovr.Reason = reason;
+ ovr.ExpiresOn = expiresOn;
+
+ var saved = await _featureFlagOverrideRepository.SaveOrUpdateAsync(ovr, cancellationToken);
+ await InvalidateDepartmentOverrideCacheAsync(departmentId);
+ PublishAudit(departmentId, userId, AuditLogTypes.FeatureFlagOverrideChanged, before, saved);
+
+ return saved;
+ }
+
+ public async Task RemoveDepartmentOverrideAsync(string key, int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ if (flag == null)
+ return false;
+
+ var existing = (await GetOverridesForDepartmentAsync(departmentId, bypassCache: true)).FirstOrDefault(o => o.FeatureFlagId == flag.FeatureFlagId);
+ if (existing == null)
+ return false;
+
+ var before = existing.CloneJsonToString();
+ await _featureFlagOverrideRepository.DeleteAsync(existing, cancellationToken);
+ await InvalidateDepartmentOverrideCacheAsync(departmentId);
+ PublishAudit(departmentId, userId, AuditLogTypes.FeatureFlagOverrideChanged, before, null);
+
+ return true;
+ }
+
+ #endregion
+
+ #region Targeting rules & prerequisites
+
+ public async Task> GetTargetingRulesForFlagAsync(string key)
+ {
+ var flag = await GetFlagByKeyAsync(key);
+ if (flag == null)
+ return new List();
+
+ return (await GetAllTargetingRulesAsync()).Where(r => r.FeatureFlagId == flag.FeatureFlagId).OrderBy(r => r.Priority).ToList();
+ }
+
+ public async Task SaveTargetingRuleAsync(FeatureFlagTargetingRule rule, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (rule == null)
+ throw new ArgumentNullException(nameof(rule));
+
+ string before = null;
+ if (rule.FeatureFlagTargetingRuleId == 0)
+ {
+ rule.CreatedOn = DateTime.UtcNow;
+ rule.CreatedByUserId = userId;
+ }
+ else
+ {
+ var existing = (await _featureFlagTargetingRuleRepository.GetAllAsync())?.FirstOrDefault(r => r.FeatureFlagTargetingRuleId == rule.FeatureFlagTargetingRuleId);
+ before = existing?.CloneJsonToString();
+ rule.UpdatedOn = DateTime.UtcNow;
+ rule.UpdatedByUserId = userId;
+ }
+
+ var saved = await _featureFlagTargetingRuleRepository.SaveOrUpdateAsync(rule, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, saved);
+
+ return saved;
+ }
+
+ public async Task RemoveTargetingRuleAsync(int targetingRuleId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var rule = (await _featureFlagTargetingRuleRepository.GetAllAsync())?.FirstOrDefault(r => r.FeatureFlagTargetingRuleId == targetingRuleId);
+ if (rule == null)
+ return false;
+
+ var before = rule.CloneJsonToString();
+ await _featureFlagTargetingRuleRepository.DeleteAsync(rule, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, null);
+
+ return true;
+ }
+
+ public async Task> GetPrerequisitesForFlagAsync(string key)
+ {
+ var flag = await GetFlagByKeyAsync(key);
+ if (flag == null)
+ return new List();
+
+ return (await GetAllPrerequisitesAsync()).Where(p => p.FeatureFlagId == flag.FeatureFlagId).ToList();
+ }
+
+ public async Task AddPrerequisiteAsync(string key, string requiredKey, string requiredValue, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flag = await GetFlagByKeyAsync(key, bypassCache: true);
+ var required = await GetFlagByKeyAsync(requiredKey, bypassCache: true);
+ if (flag == null || required == null)
+ throw new InvalidOperationException("Both the flag and the required flag must exist.");
+ if (flag.FeatureFlagId == required.FeatureFlagId)
+ throw new InvalidOperationException("A flag cannot be its own prerequisite.");
+
+ var allPrereqs = await GetAllPrerequisitesAsync();
+ if (WouldCreateCycle(allPrereqs, flag.FeatureFlagId, required.FeatureFlagId))
+ throw new InvalidOperationException("Adding this prerequisite would create a dependency cycle.");
+
+ var prereq = new FeatureFlagPrerequisite
+ {
+ FeatureFlagId = flag.FeatureFlagId,
+ RequiredFeatureFlagId = required.FeatureFlagId,
+ RequiredValue = requiredValue
+ };
+
+ var saved = await _featureFlagPrerequisiteRepository.SaveOrUpdateAsync(prereq, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, null, saved);
+
+ return saved;
+ }
+
+ public async Task RemovePrerequisiteAsync(int prerequisiteId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var prereq = (await _featureFlagPrerequisiteRepository.GetAllAsync())?.FirstOrDefault(p => p.FeatureFlagPrerequisiteId == prerequisiteId);
+ if (prereq == null)
+ return false;
+
+ var before = prereq.CloneJsonToString();
+ await _featureFlagPrerequisiteRepository.DeleteAsync(prereq, cancellationToken);
+ await InvalidateFlagCacheAsync();
+ PublishAudit(0, userId, AuditLogTypes.FeatureFlagChanged, before, null);
+
+ return true;
+ }
+
+ // Adding edge (dependent -> required) creates a cycle if 'required' can already reach 'dependent'.
+ private static bool WouldCreateCycle(List existing, int dependentId, int requiredId)
+ {
+ var adjacency = existing.GroupBy(p => p.FeatureFlagId).ToDictionary(g => g.Key, g => g.Select(p => p.RequiredFeatureFlagId).ToList());
+ var stack = new Stack();
+ var seen = new HashSet();
+ stack.Push(requiredId);
+
+ while (stack.Count > 0)
+ {
+ var current = stack.Pop();
+ if (current == dependentId)
+ return true;
+ if (!seen.Add(current))
+ continue;
+ if (adjacency.TryGetValue(current, out var next))
+ {
+ foreach (var n in next)
+ stack.Push(n);
+ }
+ }
+
+ return false;
+ }
+
+ #endregion
+
+ #region Analytics & lifecycle
+
+ private void RecordEvaluation(int featureFlagId, int departmentId, bool? isEnabled = null)
+ {
+ if (!FeatureFlagsConfig.TrackEvaluations || featureFlagId == 0)
+ return;
+
+ var key = featureFlagId + "|" + departmentId + "|" + DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+ var counter = _usageBuffer.GetOrAdd(key, _ => new long[3]);
+ Interlocked.Increment(ref counter[0]);
+
+ // counter[1] = EnabledCount, counter[2] = DisabledCount (left untouched when the
+ // resolved state is unknown, e.g. an evaluation that threw before producing a result).
+ if (isEnabled.HasValue)
+ Interlocked.Increment(ref counter[isEnabled.Value ? 1 : 2]);
+ }
+
+ public async Task FlushEvaluationsAsync(CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var flushed = 0;
+ var touchedFlagIds = new HashSet();
+
+ foreach (var key in _usageBuffer.Keys.ToList())
+ {
+ if (!_usageBuffer.TryRemove(key, out var counter))
+ continue;
+
+ var parts = key.Split('|');
+ if (parts.Length != 3
+ || !int.TryParse(parts[0], out var flagId)
+ || !int.TryParse(parts[1], out var departmentId)
+ || !DateTime.TryParseExact(parts[2], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
+ continue;
+
+ var usage = new FeatureFlagUsage
+ {
+ FeatureFlagId = flagId,
+ DepartmentId = departmentId,
+ UsageDate = date,
+ EvaluationCount = counter[0],
+ EnabledCount = counter[1],
+ DisabledCount = counter[2]
+ };
+
+ await _featureFlagUsageRepository.SaveOrUpdateAsync(usage, cancellationToken);
+ touchedFlagIds.Add(flagId);
+ flushed++;
+ }
+
+ // Refresh LastEvaluatedOn for the flags that saw traffic (no cache invalidation - stale reads bypass cache).
+ var now = DateTime.UtcNow;
+ foreach (var flagId in touchedFlagIds)
+ {
+ var flag = await _featureFlagRepository.GetByIdAsync(flagId);
+ if (flag == null)
+ continue;
+ flag.LastEvaluatedOn = now;
+ await _featureFlagRepository.SaveOrUpdateAsync(flag, cancellationToken);
+ }
+
+ return flushed;
+ }
+
+ public async Task> GetUsageForFlagAsync(string key, DateTime from, DateTime to)
+ {
+ var flag = await GetFlagByKeyAsync(key);
+ if (flag == null)
+ return new List();
+
+ var all = await _featureFlagUsageRepository.GetAllAsync();
+ return all?.Where(u => u.FeatureFlagId == flag.FeatureFlagId && u.UsageDate >= from.Date && u.UsageDate <= to.Date)
+ .OrderBy(u => u.UsageDate).ToList() ?? new List();
+ }
+
+ public async Task> GetStaleFlagsAsync(int? olderThanDays = null)
+ {
+ var threshold = olderThanDays ?? FeatureFlagsConfig.StaleFlagThresholdDays;
+ var cutoff = DateTime.UtcNow.AddDays(-Math.Abs(threshold));
+ var flags = await GetAllFlagsAsync(includeArchived: false, bypassCache: true);
+
+ return flags.Where(f => !f.IsPermanent && (!f.LastEvaluatedOn.HasValue || f.LastEvaluatedOn.Value < cutoff)).ToList();
+ }
+
+ #endregion
+
+ #region Cache invalidation & audit
+
+ public async Task InvalidateFlagCacheAsync()
+ {
+ await _cacheProvider.RemoveAsync(AllFlagsCacheKey);
+ await _cacheProvider.RemoveAsync(AllRulesCacheKey);
+ await _cacheProvider.RemoveAsync(AllPrereqsCacheKey);
+ }
+
+ public async Task InvalidateDepartmentOverrideCacheAsync(int departmentId)
+ {
+ await _cacheProvider.RemoveAsync(string.Format(DepartmentOverridesCacheKey, departmentId));
+ }
+
+ private void PublishAudit(int departmentId, string userId, AuditLogTypes type, string before, object after)
+ {
+ try
+ {
+ _eventAggregator.SendMessage(new AuditEvent
+ {
+ DepartmentId = departmentId,
+ UserId = userId,
+ Type = type,
+ Before = before,
+ After = after == null ? null : after.CloneJsonToString(),
+ Successful = true
+ });
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, "Failed to publish feature flag audit event");
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index dc3b91d9c..8cf8e90dc 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -47,6 +47,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0071_AddingFeatureToggles.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0071_AddingFeatureToggles.cs
new file mode 100644
index 000000000..09fc550bf
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0071_AddingFeatureToggles.cs
@@ -0,0 +1,155 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(71)]
+ public class M0071_AddingFeatureToggles : Migration
+ {
+ public override void Up()
+ {
+ // FeatureFlags - system-wide flag definitions with a global default.
+ Create.Table("FeatureFlags")
+ .WithColumn("FeatureFlagId").AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FlagKey").AsString(256).NotNullable()
+ .WithColumn("Name").AsString(256).NotNullable()
+ .WithColumn("Description").AsString(1000).Nullable()
+ .WithColumn("Category").AsString(128).Nullable()
+ .WithColumn("Tags").AsString(512).Nullable()
+ .WithColumn("FlagType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("IsEnabledGlobally").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("DefaultValue").AsString(int.MaxValue).Nullable()
+ .WithColumn("OffValue").AsString(int.MaxValue).Nullable()
+ .WithColumn("RolloutPercentage").AsInt32().Nullable()
+ .WithColumn("MinimumPlanType").AsInt32().Nullable()
+ .WithColumn("Environment").AsInt32().Nullable()
+ .WithColumn("EnableOn").AsDateTime2().Nullable()
+ .WithColumn("DisableOn").AsDateTime2().Nullable()
+ .WithColumn("IsArchived").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("IsPermanent").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("LastEvaluatedOn").AsDateTime2().Nullable()
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CreatedByUserId").AsString(450).Nullable()
+ .WithColumn("UpdatedOn").AsDateTime2().Nullable()
+ .WithColumn("UpdatedByUserId").AsString(450).Nullable();
+
+ Create.Index("UX_FeatureFlags_FlagKey")
+ .OnTable("FeatureFlags")
+ .OnColumn("FlagKey").Ascending()
+ .WithOptions().Unique();
+
+ // FeatureFlagOverrides - per-department override of a flag's value.
+ Create.Table("FeatureFlagOverrides")
+ .WithColumn("FeatureFlagOverrideId").AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId").AsInt32().NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("FlagValue").AsString(int.MaxValue).Nullable()
+ .WithColumn("Reason").AsString(512).Nullable()
+ .WithColumn("ExpiresOn").AsDateTime2().Nullable()
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CreatedByUserId").AsString(450).Nullable()
+ .WithColumn("UpdatedOn").AsDateTime2().Nullable()
+ .WithColumn("UpdatedByUserId").AsString(450).Nullable();
+
+ Create.ForeignKey("FK_FeatureFlagOverrides_FeatureFlags")
+ .FromTable("FeatureFlagOverrides").ForeignColumn("FeatureFlagId")
+ .ToTable("FeatureFlags").PrimaryColumn("FeatureFlagId");
+
+ Create.ForeignKey("FK_FeatureFlagOverrides_Departments")
+ .FromTable("FeatureFlagOverrides").ForeignColumn("DepartmentId")
+ .ToTable("Departments").PrimaryColumn("DepartmentId");
+
+ Create.Index("UX_FeatureFlagOverrides_Flag_Department")
+ .OnTable("FeatureFlagOverrides")
+ .OnColumn("FeatureFlagId").Ascending()
+ .OnColumn("DepartmentId").Ascending()
+ .WithOptions().Unique();
+
+ // FeatureFlagTargetingRules - attribute/segment targeting rules.
+ Create.Table("FeatureFlagTargetingRules")
+ .WithColumn("FeatureFlagTargetingRuleId").AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId").AsInt32().NotNullable()
+ .WithColumn("Priority").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("AttributeType").AsInt32().NotNullable()
+ .WithColumn("OperatorType").AsInt32().NotNullable()
+ .WithColumn("ComparisonValue").AsString(int.MaxValue).Nullable()
+ .WithColumn("ResultEnabled").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("ResultValue").AsString(int.MaxValue).Nullable()
+ .WithColumn("RolloutPercentage").AsInt32().Nullable()
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CreatedByUserId").AsString(450).Nullable()
+ .WithColumn("UpdatedOn").AsDateTime2().Nullable()
+ .WithColumn("UpdatedByUserId").AsString(450).Nullable();
+
+ Create.ForeignKey("FK_FeatureFlagTargetingRules_FeatureFlags")
+ .FromTable("FeatureFlagTargetingRules").ForeignColumn("FeatureFlagId")
+ .ToTable("FeatureFlags").PrimaryColumn("FeatureFlagId");
+
+ Create.Index("IX_FeatureFlagTargetingRules_Flag")
+ .OnTable("FeatureFlagTargetingRules")
+ .OnColumn("FeatureFlagId").Ascending();
+
+ // FeatureFlagPrerequisites - flag dependency edges.
+ Create.Table("FeatureFlagPrerequisites")
+ .WithColumn("FeatureFlagPrerequisiteId").AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId").AsInt32().NotNullable()
+ .WithColumn("RequiredFeatureFlagId").AsInt32().NotNullable()
+ .WithColumn("RequiredValue").AsString(int.MaxValue).Nullable();
+
+ Create.ForeignKey("FK_FeatureFlagPrerequisites_FeatureFlags")
+ .FromTable("FeatureFlagPrerequisites").ForeignColumn("FeatureFlagId")
+ .ToTable("FeatureFlags").PrimaryColumn("FeatureFlagId");
+
+ Create.ForeignKey("FK_FeatureFlagPrerequisites_RequiredFeatureFlag")
+ .FromTable("FeatureFlagPrerequisites").ForeignColumn("RequiredFeatureFlagId")
+ .ToTable("FeatureFlags").PrimaryColumn("FeatureFlagId");
+
+ Create.Index("IX_FeatureFlagPrerequisites_Flag")
+ .OnTable("FeatureFlagPrerequisites")
+ .OnColumn("FeatureFlagId").Ascending();
+
+ // FeatureFlagUsages - aggregated daily evaluation counts (append-only flushes).
+ Create.Table("FeatureFlagUsages")
+ .WithColumn("FeatureFlagUsageId").AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId").AsInt32().NotNullable()
+ .WithColumn("DepartmentId").AsInt32().Nullable()
+ .WithColumn("UsageDate").AsDateTime2().NotNullable()
+ .WithColumn("EvaluationCount").AsInt64().NotNullable().WithDefaultValue(0)
+ .WithColumn("EnabledCount").AsInt64().NotNullable().WithDefaultValue(0)
+ .WithColumn("DisabledCount").AsInt64().NotNullable().WithDefaultValue(0);
+
+ Create.ForeignKey("FK_FeatureFlagUsages_FeatureFlags")
+ .FromTable("FeatureFlagUsages").ForeignColumn("FeatureFlagId")
+ .ToTable("FeatureFlags").PrimaryColumn("FeatureFlagId");
+
+ Create.ForeignKey("FK_FeatureFlagUsages_Departments")
+ .FromTable("FeatureFlagUsages").ForeignColumn("DepartmentId")
+ .ToTable("Departments").PrimaryColumn("DepartmentId");
+
+ Create.Index("IX_FeatureFlagUsages_Flag_Date")
+ .OnTable("FeatureFlagUsages")
+ .OnColumn("FeatureFlagId").Ascending()
+ .OnColumn("UsageDate").Ascending();
+ }
+
+ public override void Down()
+ {
+ Delete.ForeignKey("FK_FeatureFlagUsages_Departments").OnTable("FeatureFlagUsages");
+ Delete.ForeignKey("FK_FeatureFlagUsages_FeatureFlags").OnTable("FeatureFlagUsages");
+ Delete.Table("FeatureFlagUsages");
+
+ Delete.ForeignKey("FK_FeatureFlagPrerequisites_RequiredFeatureFlag").OnTable("FeatureFlagPrerequisites");
+ Delete.ForeignKey("FK_FeatureFlagPrerequisites_FeatureFlags").OnTable("FeatureFlagPrerequisites");
+ Delete.Table("FeatureFlagPrerequisites");
+
+ Delete.ForeignKey("FK_FeatureFlagTargetingRules_FeatureFlags").OnTable("FeatureFlagTargetingRules");
+ Delete.Table("FeatureFlagTargetingRules");
+
+ Delete.ForeignKey("FK_FeatureFlagOverrides_Departments").OnTable("FeatureFlagOverrides");
+ Delete.ForeignKey("FK_FeatureFlagOverrides_FeatureFlags").OnTable("FeatureFlagOverrides");
+ Delete.Table("FeatureFlagOverrides");
+
+ Delete.Table("FeatureFlags");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0072_AddingChatbotTwilioFeatureFlag.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0072_AddingChatbotTwilioFeatureFlag.cs
new file mode 100644
index 000000000..dd283ecd9
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0072_AddingChatbotTwilioFeatureFlag.cs
@@ -0,0 +1,31 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(72)]
+ public class M0072_AddingChatbotTwilioFeatureFlag : Migration
+ {
+ // Keep FlagKey in sync with Resgrid.Model.FeatureFlagKeys.ChatbotTwilioTextIntegration.
+ private const string FlagKey = "Chatbot.TwilioTextIntegration";
+
+ public override void Up()
+ {
+ // Seeded OFF (IsEnabledGlobally = false). Inbound Twilio SMS keeps the original text-command
+ // handling until this flag is enabled globally or via a per-department override. FlagType,
+ // IsArchived, IsPermanent and CreatedOn fall back to their table defaults.
+ Insert.IntoTable("FeatureFlags").Row(new
+ {
+ FlagKey = FlagKey,
+ Name = "Chatbot Twilio Text Integration",
+ Description = "When enabled, inbound Twilio SMS is processed by the new chatbot ingress pipeline. When off (globally or for a department) the original text-command handling is used.",
+ Category = "Chatbot",
+ IsEnabledGlobally = false
+ });
+ }
+
+ public override void Down()
+ {
+ Delete.FromTable("FeatureFlags").Row(new { FlagKey = FlagKey });
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0071_AddingFeatureTogglesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0071_AddingFeatureTogglesPg.cs
new file mode 100644
index 000000000..d841fdf2a
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0071_AddingFeatureTogglesPg.cs
@@ -0,0 +1,155 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(71)]
+ public class M0071_AddingFeatureTogglesPg : Migration
+ {
+ public override void Up()
+ {
+ // FeatureFlags - system-wide flag definitions with a global default.
+ Create.Table("FeatureFlags".ToLower())
+ .WithColumn("FeatureFlagId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FlagKey".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("Name".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("Description".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("Category".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Tags".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("FlagType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("IsEnabledGlobally".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("DefaultValue".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("OffValue".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("RolloutPercentage".ToLower()).AsInt32().Nullable()
+ .WithColumn("MinimumPlanType".ToLower()).AsInt32().Nullable()
+ .WithColumn("Environment".ToLower()).AsInt32().Nullable()
+ .WithColumn("EnableOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("DisableOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("IsArchived".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("IsPermanent".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("LastEvaluatedOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("UpdatedOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("UpdatedByUserId".ToLower()).AsCustom("citext").Nullable();
+
+ Create.Index("UX_FeatureFlags_FlagKey".ToLower())
+ .OnTable("FeatureFlags".ToLower())
+ .OnColumn("FlagKey".ToLower()).Ascending()
+ .WithOptions().Unique();
+
+ // FeatureFlagOverrides - per-department override of a flag's value.
+ Create.Table("FeatureFlagOverrides".ToLower())
+ .WithColumn("FeatureFlagOverrideId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("IsEnabled".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("FlagValue".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("Reason".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("ExpiresOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("UpdatedOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("UpdatedByUserId".ToLower()).AsCustom("citext").Nullable();
+
+ Create.ForeignKey("FK_FeatureFlagOverrides_FeatureFlags".ToLower())
+ .FromTable("FeatureFlagOverrides".ToLower()).ForeignColumn("FeatureFlagId".ToLower())
+ .ToTable("FeatureFlags".ToLower()).PrimaryColumn("FeatureFlagId".ToLower());
+
+ Create.ForeignKey("FK_FeatureFlagOverrides_Departments".ToLower())
+ .FromTable("FeatureFlagOverrides".ToLower()).ForeignColumn("DepartmentId".ToLower())
+ .ToTable("Departments".ToLower()).PrimaryColumn("DepartmentId".ToLower());
+
+ Create.Index("UX_FeatureFlagOverrides_Flag_Department".ToLower())
+ .OnTable("FeatureFlagOverrides".ToLower())
+ .OnColumn("FeatureFlagId".ToLower()).Ascending()
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .WithOptions().Unique();
+
+ // FeatureFlagTargetingRules - attribute/segment targeting rules.
+ Create.Table("FeatureFlagTargetingRules".ToLower())
+ .WithColumn("FeatureFlagTargetingRuleId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("Priority".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("AttributeType".ToLower()).AsInt32().NotNullable()
+ .WithColumn("OperatorType".ToLower()).AsInt32().NotNullable()
+ .WithColumn("ComparisonValue".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("ResultEnabled".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("ResultValue".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("RolloutPercentage".ToLower()).AsInt32().Nullable()
+ .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("UpdatedOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("UpdatedByUserId".ToLower()).AsCustom("citext").Nullable();
+
+ Create.ForeignKey("FK_FeatureFlagTargetingRules_FeatureFlags".ToLower())
+ .FromTable("FeatureFlagTargetingRules".ToLower()).ForeignColumn("FeatureFlagId".ToLower())
+ .ToTable("FeatureFlags".ToLower()).PrimaryColumn("FeatureFlagId".ToLower());
+
+ Create.Index("IX_FeatureFlagTargetingRules_Flag".ToLower())
+ .OnTable("FeatureFlagTargetingRules".ToLower())
+ .OnColumn("FeatureFlagId".ToLower()).Ascending();
+
+ // FeatureFlagPrerequisites - flag dependency edges.
+ Create.Table("FeatureFlagPrerequisites".ToLower())
+ .WithColumn("FeatureFlagPrerequisiteId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("RequiredFeatureFlagId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("RequiredValue".ToLower()).AsCustom("text").Nullable();
+
+ Create.ForeignKey("FK_FeatureFlagPrerequisites_FeatureFlags".ToLower())
+ .FromTable("FeatureFlagPrerequisites".ToLower()).ForeignColumn("FeatureFlagId".ToLower())
+ .ToTable("FeatureFlags".ToLower()).PrimaryColumn("FeatureFlagId".ToLower());
+
+ Create.ForeignKey("FK_FeatureFlagPrerequisites_RequiredFeatureFlag".ToLower())
+ .FromTable("FeatureFlagPrerequisites".ToLower()).ForeignColumn("RequiredFeatureFlagId".ToLower())
+ .ToTable("FeatureFlags".ToLower()).PrimaryColumn("FeatureFlagId".ToLower());
+
+ Create.Index("IX_FeatureFlagPrerequisites_Flag".ToLower())
+ .OnTable("FeatureFlagPrerequisites".ToLower())
+ .OnColumn("FeatureFlagId".ToLower()).Ascending();
+
+ // FeatureFlagUsages - aggregated daily evaluation counts (append-only flushes).
+ Create.Table("FeatureFlagUsages".ToLower())
+ .WithColumn("FeatureFlagUsageId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity()
+ .WithColumn("FeatureFlagId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().Nullable()
+ .WithColumn("UsageDate".ToLower()).AsDateTime2().NotNullable()
+ .WithColumn("EvaluationCount".ToLower()).AsInt64().NotNullable().WithDefaultValue(0)
+ .WithColumn("EnabledCount".ToLower()).AsInt64().NotNullable().WithDefaultValue(0)
+ .WithColumn("DisabledCount".ToLower()).AsInt64().NotNullable().WithDefaultValue(0);
+
+ Create.ForeignKey("FK_FeatureFlagUsages_FeatureFlags".ToLower())
+ .FromTable("FeatureFlagUsages".ToLower()).ForeignColumn("FeatureFlagId".ToLower())
+ .ToTable("FeatureFlags".ToLower()).PrimaryColumn("FeatureFlagId".ToLower());
+
+ Create.ForeignKey("FK_FeatureFlagUsages_Departments".ToLower())
+ .FromTable("FeatureFlagUsages".ToLower()).ForeignColumn("DepartmentId".ToLower())
+ .ToTable("Departments".ToLower()).PrimaryColumn("DepartmentId".ToLower());
+
+ Create.Index("IX_FeatureFlagUsages_Flag_Date".ToLower())
+ .OnTable("FeatureFlagUsages".ToLower())
+ .OnColumn("FeatureFlagId".ToLower()).Ascending()
+ .OnColumn("UsageDate".ToLower()).Ascending();
+ }
+
+ public override void Down()
+ {
+ Delete.ForeignKey("FK_FeatureFlagUsages_Departments".ToLower()).OnTable("FeatureFlagUsages".ToLower());
+ Delete.ForeignKey("FK_FeatureFlagUsages_FeatureFlags".ToLower()).OnTable("FeatureFlagUsages".ToLower());
+ Delete.Table("FeatureFlagUsages".ToLower());
+
+ Delete.ForeignKey("FK_FeatureFlagPrerequisites_RequiredFeatureFlag".ToLower()).OnTable("FeatureFlagPrerequisites".ToLower());
+ Delete.ForeignKey("FK_FeatureFlagPrerequisites_FeatureFlags".ToLower()).OnTable("FeatureFlagPrerequisites".ToLower());
+ Delete.Table("FeatureFlagPrerequisites".ToLower());
+
+ Delete.ForeignKey("FK_FeatureFlagTargetingRules_FeatureFlags".ToLower()).OnTable("FeatureFlagTargetingRules".ToLower());
+ Delete.Table("FeatureFlagTargetingRules".ToLower());
+
+ Delete.ForeignKey("FK_FeatureFlagOverrides_Departments".ToLower()).OnTable("FeatureFlagOverrides".ToLower());
+ Delete.ForeignKey("FK_FeatureFlagOverrides_FeatureFlags".ToLower()).OnTable("FeatureFlagOverrides".ToLower());
+ Delete.Table("FeatureFlagOverrides".ToLower());
+
+ Delete.Table("FeatureFlags".ToLower());
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0072_AddingChatbotTwilioFeatureFlagPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0072_AddingChatbotTwilioFeatureFlagPg.cs
new file mode 100644
index 000000000..c65515123
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0072_AddingChatbotTwilioFeatureFlagPg.cs
@@ -0,0 +1,32 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(72)]
+ public class M0072_AddingChatbotTwilioFeatureFlagPg : Migration
+ {
+ // Keep FlagKey in sync with Resgrid.Model.FeatureFlagKeys.ChatbotTwilioTextIntegration.
+ private const string FlagKey = "Chatbot.TwilioTextIntegration";
+
+ public override void Up()
+ {
+ // Seeded OFF (isenabledglobally = false). Inbound Twilio SMS keeps the original text-command
+ // handling until this flag is enabled globally or via a per-department override. flagtype,
+ // isarchived, ispermanent and createdon fall back to their table defaults; the identity PK is
+ // omitted so Postgres assigns it.
+ Insert.IntoTable("FeatureFlags".ToLower()).Row(new
+ {
+ flagkey = FlagKey,
+ name = "Chatbot Twilio Text Integration",
+ description = "When enabled, inbound Twilio SMS is processed by the new chatbot ingress pipeline. When off (globally or for a department) the original text-command handling is used.",
+ category = "Chatbot",
+ isenabledglobally = false
+ });
+ }
+
+ public override void Down()
+ {
+ Delete.FromTable("FeatureFlags".ToLower()).Row(new { flagkey = FlagKey });
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagOverrideRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagOverrideRepository.cs
new file mode 100644
index 000000000..6e7d67d23
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagOverrideRepository.cs
@@ -0,0 +1,16 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class FeatureFlagOverrideRepository : RepositoryBase, IFeatureFlagOverrideRepository
+ {
+ public FeatureFlagOverrideRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagPrerequisiteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagPrerequisiteRepository.cs
new file mode 100644
index 000000000..3b3abf927
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagPrerequisiteRepository.cs
@@ -0,0 +1,16 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class FeatureFlagPrerequisiteRepository : RepositoryBase, IFeatureFlagPrerequisiteRepository
+ {
+ public FeatureFlagPrerequisiteRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs
new file mode 100644
index 000000000..248d94db5
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs
@@ -0,0 +1,16 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class FeatureFlagRepository : RepositoryBase, IFeatureFlagRepository
+ {
+ public FeatureFlagRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagTargetingRuleRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagTargetingRuleRepository.cs
new file mode 100644
index 000000000..25bcbdd6c
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagTargetingRuleRepository.cs
@@ -0,0 +1,16 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class FeatureFlagTargetingRuleRepository : RepositoryBase, IFeatureFlagTargetingRuleRepository
+ {
+ public FeatureFlagTargetingRuleRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagUsageRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagUsageRepository.cs
new file mode 100644
index 000000000..7fb3567f3
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagUsageRepository.cs
@@ -0,0 +1,16 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class FeatureFlagUsageRepository : RepositoryBase, IFeatureFlagUsageRepository
+ {
+ public FeatureFlagUsageRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
index acb273c01..ca946e273 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
@@ -36,6 +36,11 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
index 5fc0d26c7..9294e6e91 100644
--- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
+++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
@@ -45,6 +45,7 @@ public class TwilioControllerVoiceVerificationTests : TestBase
private Mock _encryptionServiceMock;
private Mock _twilioVoiceResponseServiceMock;
private Mock _chatbotIngressServiceMock;
+ private Mock _featureToggleServiceMock;
protected override void Before_all_tests()
{
@@ -68,6 +69,7 @@ protected override void Before_all_tests()
_communicationTestServiceMock = new Mock();
_encryptionServiceMock = new Mock();
_chatbotIngressServiceMock = new Mock();
+ _featureToggleServiceMock = new Mock();
_twilioVoiceResponseServiceMock = new Mock();
_departmentSettingsServiceMock.Setup(x => x.GetTtsLanguageForDepartmentAsync(It.IsAny())).ReturnsAsync((string)null);
_twilioVoiceResponseServiceMock
@@ -148,7 +150,8 @@ private TwilioController BuildController()
_communicationTestServiceMock.Object,
_encryptionServiceMock.Object,
_twilioVoiceResponseServiceMock.Object,
- _chatbotIngressServiceMock.Object);
+ _chatbotIngressServiceMock.Object,
+ _featureToggleServiceMock.Object);
}
private static string InvokeBuildDispatchPrompt(Type controllerType, Call call, string address)
diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs
index a471e0e4a..1da24d86e 100644
--- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs
@@ -55,6 +55,7 @@ public class TwilioController : ControllerBase
private readonly IEncryptionService _encryptionService;
private readonly ITwilioVoiceResponseService _twilioVoiceResponseService;
private readonly IChatbotIngressService _chatbotIngressService;
+ private readonly IFeatureToggleService _featureToggleService;
public TwilioController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService,
ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService,
@@ -63,7 +64,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN
IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService,
IUsersService usersService, ICalendarService calendarService, ICommunicationTestService communicationTestService,
IEncryptionService encryptionService, ITwilioVoiceResponseService twilioVoiceResponseService,
- IChatbotIngressService chatbotIngressService)
+ IChatbotIngressService chatbotIngressService, IFeatureToggleService featureToggleService)
{
_departmentSettingsService = departmentSettingsService;
_numbersService = numbersService;
@@ -86,6 +87,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN
_encryptionService = encryptionService;
_twilioVoiceResponseService = twilioVoiceResponseService;
_chatbotIngressService = chatbotIngressService;
+ _featureToggleService = featureToggleService;
}
#endregion Private Readonly Properties and Constructors
@@ -136,23 +138,55 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques
try
{
- // Use the chatbot ingress service for all text command processing
- var chatbotMessage = new ChatbotMessage
+ // Resolve the department that owns the inbound number (falling back to the sender's
+ // linked profile) so the Chatbot Twilio integration flag can be evaluated per-department.
+ UserProfile userProfile = null;
+ var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To);
+ if (!departmentId.HasValue)
{
- MessageId = request.MessageSid ?? Guid.NewGuid().ToString("N"),
- From = request.From?.Replace("+", ""),
- To = request.To?.Replace("+", ""),
- Text = request.Body,
- Platform = ChatbotPlatform.SmsTwilio,
- Timestamp = DateTime.UtcNow
- };
+ userProfile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn);
+ if (userProfile != null)
+ {
+ var department = await _departmentsService.GetDepartmentByUserIdAsync(userProfile.UserId);
+ if (department != null)
+ departmentId = department.DepartmentId;
+ }
+ }
- var chatbotResponse = await _chatbotIngressService.ProcessMessageAsync(chatbotMessage);
+ // Carry the resolved department onto the inbound message event so chatbot-routed events
+ // retain the same department context the text-command path records.
+ if (departmentId.HasValue)
+ messageEvent.CustomerId = departmentId.Value.ToString();
- if (chatbotResponse.Processed)
- messageEvent.Processed = true;
+ // Feature-flagged rollout: the chatbot ingress is the new path. When the flag is off
+ // (globally or for this department) fall back to the original text-command handling so
+ // existing behavior is preserved.
+ var chatbotEnabled = await _featureToggleService.IsEnabledAsync(
+ FeatureFlagKeys.ChatbotTwilioTextIntegration, departmentId ?? 0, false);
+
+ if (chatbotEnabled)
+ {
+ var chatbotMessage = new ChatbotMessage
+ {
+ MessageId = request.MessageSid ?? Guid.NewGuid().ToString("N"),
+ From = request.From?.Replace("+", ""),
+ To = request.To?.Replace("+", ""),
+ Text = request.Body,
+ Platform = ChatbotPlatform.SmsTwilio,
+ Timestamp = DateTime.UtcNow
+ };
- response.Message(chatbotResponse.Text);
+ var chatbotResponse = await _chatbotIngressService.ProcessMessageAsync(chatbotMessage);
+
+ if (chatbotResponse.Processed)
+ messageEvent.Processed = true;
+
+ response.Message(chatbotResponse.Text);
+ }
+ else
+ {
+ await ProcessTextCommandsAsync(textMessage, messageEvent, response, departmentId, userProfile);
+ }
}
catch (Exception ex)
{
@@ -172,6 +206,332 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques
};
}
+ // Original (pre-chatbot) inbound text-command handling. Retained so departments that have not
+ // enabled the Chatbot Twilio integration feature flag keep their existing behavior. departmentId
+ // and userProfile are resolved by the caller so the flag can be evaluated before dispatching here.
+ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage textMessage, InboundMessageEvent messageEvent,
+ MessagingResponse response, int? departmentId, UserProfile userProfile)
+ {
+ if (departmentId.HasValue)
+ {
+ // Run all department-level lookups in parallel — they are independent of each other.
+ var departmentTask = _departmentsService.GetDepartmentByIdAsync(departmentId.Value);
+ var dispatchNumbersTask = _departmentSettingsService.GetTextToCallSourceNumbersForDepartmentAsync(departmentId.Value);
+ var authorizedTask = _limitsService.CanDepartmentProvisionNumberAsync(departmentId.Value);
+ var customStatesTask = _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId.Value);
+
+ await System.Threading.Tasks.Task.WhenAll(departmentTask, dispatchNumbersTask, authorizedTask, customStatesTask);
+
+ var department = departmentTask.Result;
+ var dispatchNumbers = dispatchNumbersTask.Result;
+ var authroized = authorizedTask.Result;
+ var customStates = customStatesTask.Result;
+
+ messageEvent.CustomerId = departmentId.Value.ToString();
+
+ if (authroized)
+ {
+ bool isDispatchSource = false;
+
+ if (!String.IsNullOrWhiteSpace(dispatchNumbers))
+ isDispatchSource = _numbersService.DoesNumberMatchAnyPattern(dispatchNumbers.Split(Char.Parse(",")).ToList(), textMessage.Msisdn);
+
+ if (isDispatchSource)
+ {
+ var c = new Call();
+ c.Notes = textMessage.Text;
+ c.NatureOfCall = textMessage.Text;
+ c.LoggedOn = DateTime.UtcNow;
+ c.Name = string.Format("TTC {0}", c.LoggedOn.TimeConverter(department).ToString("g"));
+ c.Priority = (int)CallPriority.High;
+ c.ReportingUserId = department.ManagingUserId;
+ c.Dispatches = new Collection();
+ c.CallSource = (int)CallSources.EmailImport;
+ c.SourceIdentifier = textMessage.MessageId;
+ c.DepartmentId = departmentId.Value;
+
+ var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId.Value, true);
+ foreach (var u in users)
+ {
+ var cd = new CallDispatch();
+ cd.UserId = u.UserId;
+
+ c.Dispatches.Add(cd);
+ }
+
+ var savedCall = await _callsService.SaveCallAsync(c);
+
+ var cqi = new CallQueueItem();
+ cqi.Call = savedCall;
+ cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(users.Select(x => x.UserId).ToList());
+ cqi.DepartmentTextNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(cqi.Call.DepartmentId);
+
+ await _queueService.EnqueueCallBroadcastAsync(cqi);
+
+ messageEvent.Processed = true;
+ }
+
+ if (!isDispatchSource)
+ {
+ // Reuse the profile fetched above when the department was resolved via mobile number;
+ // only hit the DB again if the department came from the phone-number lookup path.
+ var profile = userProfile ?? await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn);
+
+ if (profile != null)
+ {
+ var payload = _textCommandService.DetermineType(textMessage.Text);
+ var customActions = customStates.FirstOrDefault(x => x.Type == (int)CustomStateTypes.Personnel);
+ var customStaffing = customStates.FirstOrDefault(x => x.Type == (int)CustomStateTypes.Staffing);
+
+ switch (payload.Type)
+ {
+ case TextCommandTypes.None:
+ response.Message("Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands.");
+ break;
+ case TextCommandTypes.Help:
+ messageEvent.Processed = true;
+
+ var help = new StringBuilder();
+ help.Append("Resgrid Text Commands" + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("These are the commands you can text to alter your status and staffing. Text help for help." + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("Core Commands" + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("STOP: To turn off all text messages" + Environment.NewLine);
+ help.Append("HELP: This help text" + Environment.NewLine);
+ help.Append("CALLS: List active calls" + Environment.NewLine);
+ help.Append("C[CallId]: Get Call Detail i.e. C1445" + Environment.NewLine);
+ help.Append("UNITS: List unit statuses" + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("Status or Action Commands" + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+
+ if (customActions != null && customActions.IsDeleted == false && customActions.GetActiveDetails() != null && customActions.GetActiveDetails().Any())
+ {
+ var activeDetails = customActions.GetActiveDetails();
+ for (int i = 0; i < activeDetails.Count; i++)
+ {
+ help.Append($"{activeDetails[i].ButtonText.Trim().Replace(" ", "").Replace("-", "").Replace(":", "")} or {i + 1}: {activeDetails[i].ButtonText}" + Environment.NewLine);
+ }
+ }
+ else
+ {
+ help.Append("responding or 1: Responding" + Environment.NewLine);
+ help.Append("notresponding or 2: Not Responding" + Environment.NewLine);
+ help.Append("onscene or 3: On Scene" + Environment.NewLine);
+ help.Append("available or 4: Available" + Environment.NewLine);
+ }
+
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("Staffing Commands" + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+
+ if (customStaffing != null && customStaffing.IsDeleted == false && customStaffing.GetActiveDetails() != null && customStaffing.GetActiveDetails().Any())
+ {
+ var activeDetails = customStaffing.GetActiveDetails();
+ for (int i = 0; i < activeDetails.Count; i++)
+ {
+ help.Append($"{activeDetails[i].ButtonText.Trim().Replace(" ", "").Replace("-", "").Replace(":", "")} or S{i + 1}: {activeDetails[i].ButtonText}" + Environment.NewLine);
+ }
+ }
+ else
+ {
+ help.Append("available or s1: Available Staffing" + Environment.NewLine);
+ help.Append("delayed or s2: Delayed Staffing" + Environment.NewLine);
+ help.Append("unavailable or s3: Unavailable Staffing" + Environment.NewLine);
+ help.Append("committed or s4: Committed Staffing" + Environment.NewLine);
+ help.Append("onshift or s4: On Shift Staffing" + Environment.NewLine);
+ }
+
+ response.Message(help.ToString());
+
+ //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", help.ToString(), department.DepartmentId, textMessage.To, profile);
+ break;
+ case TextCommandTypes.Action:
+ messageEvent.Processed = true;
+ await _actionLogsService.SetUserActionAsync(profile.UserId, department.DepartmentId, (int)payload.GetActionType());
+ response.Message(string.Format("Resgrid received your text command. Status changed to: {0}", payload.GetActionType()));
+ //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Status", string.Format("Resgrid recieved your text command. Status changed to: {0}", payload.GetActionType()), department.DepartmentId, textMessage.To, profile);
+ break;
+ case TextCommandTypes.Staffing:
+ messageEvent.Processed = true;
+ await _userStateService.CreateUserState(profile.UserId, department.DepartmentId, (int)payload.GetStaffingType());
+ response.Message(string.Format("Resgrid received your text command. Staffing level changed to: {0}", payload.GetStaffingType()));
+ //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Staffing", string.Format("Resgrid recieved your text command. Staffing level changed to: {0}", payload.GetStaffingType()), department.DepartmentId, textMessage.To, profile);
+ break;
+ case TextCommandTypes.Stop:
+ messageEvent.Processed = true;
+ await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId);
+ response.Message("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.");
+ break;
+ case TextCommandTypes.CustomAction:
+ messageEvent.Processed = true;
+ await _actionLogsService.SetUserActionAsync(profile.UserId, department.DepartmentId, payload.GetCustomActionType());
+
+ if (customActions != null && customActions.IsDeleted == false && customActions.GetActiveDetails() != null && customActions.GetActiveDetails().Any() &&
+ customActions.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomActionType()) != null)
+ {
+ var detail = customActions.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomActionType());
+ response.Message(string.Format("Resgrid received your text command. Status changed to: {0}", detail.ButtonText));
+ }
+ else
+ {
+ response.Message("Resgrid received your text command and updated your status");
+ }
+
+ break;
+ case TextCommandTypes.CustomStaffing:
+ messageEvent.Processed = true;
+ await _userStateService.CreateUserState(profile.UserId, department.DepartmentId, payload.GetCustomStaffingType());
+
+ if (customStaffing != null && customStaffing.IsDeleted == false && customStaffing.GetActiveDetails() != null && customStaffing.GetActiveDetails().Any() &&
+ customStaffing.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomStaffingType()) != null)
+ {
+ var detail = customStaffing.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomStaffingType());
+ response.Message(string.Format("Resgrid received your text command. Staffing changed to: {0}", detail.ButtonText));
+ }
+ else
+ {
+ response.Message("Resgrid received your text command and updated your staffing");
+ }
+
+ break;
+ case TextCommandTypes.MyStatus:
+ messageEvent.Processed = true;
+
+
+ var userStatus = await _actionLogsService.GetLastActionLogForUserAsync(profile.UserId);
+ var userStaffing = await _userStateService.GetLastUserStateByUserIdAsync(profile.UserId);
+
+ var customStatusLevel = await _customStateService.GetCustomPersonnelStatusAsync(department.DepartmentId, userStatus);
+ var customStaffingLevel = await _customStateService.GetCustomPersonnelStaffingAsync(department.DepartmentId, userStaffing);
+
+ response.Message(
+ $"Hello {profile.FullName.AsFirstNameLastName} at {DateTime.UtcNow.TimeConverterToString(department)} your current status is {customStatusLevel.ButtonText} and your current staffing is {customStaffingLevel.ButtonText}.");
+ break;
+ case TextCommandTypes.Calls:
+ messageEvent.Processed = true;
+
+ var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(department.DepartmentId);
+
+ var activeCallText = new StringBuilder();
+ activeCallText.Append($"Active Calls for {department.Name}" + Environment.NewLine);
+ activeCallText.Append("---------------------" + Environment.NewLine);
+
+ foreach (var activeCall in activeCalls)
+ {
+ activeCallText.Append($"CallId: {activeCall.CallId} Name: {activeCall.Name} Nature:{StringHelpers.StripHtmlTagsCharArray(activeCall.NatureOfCall)}" + Environment.NewLine);
+ }
+
+ response.Message(activeCallText.ToString().Truncate(1200));
+ break;
+ case TextCommandTypes.Units:
+ messageEvent.Processed = true;
+
+ var unitStatus = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(department.DepartmentId);
+
+ var unitStatusesText = new StringBuilder();
+ unitStatusesText.Append($"Unit Statuses for {department.Name}" + Environment.NewLine);
+ unitStatusesText.Append("---------------------" + Environment.NewLine);
+
+ foreach (var unit in unitStatus)
+ {
+ var unitState = await _customStateService.GetCustomUnitStateAsync(unit);
+ unitStatusesText.Append($"{unit.Unit.Name} is {unitState.ButtonText}" + Environment.NewLine);
+ }
+
+ response.Message(unitStatusesText.ToString().Truncate(1200));
+ break;
+ case TextCommandTypes.CallDetail:
+ messageEvent.Processed = true;
+
+ var call = await _callsService.GetCallByIdAsync(int.Parse(payload.Data));
+
+ // Guard against a missing call (NRE) and against reading a call that belongs
+ // to another department (cross-department data leakage).
+ if (call == null || call.DepartmentId != department.DepartmentId)
+ {
+ response.Message("Resgrid could not find that call.");
+ break;
+ }
+
+ var callText = new StringBuilder();
+ callText.Append($"Call Information for {call.Name}" + Environment.NewLine);
+ callText.Append("---------------------" + Environment.NewLine);
+ callText.Append($"Id: {call.CallId}" + Environment.NewLine);
+ callText.Append($"Number: {call.Number}" + Environment.NewLine);
+ callText.Append($"Logged: {call.LoggedOn.TimeConverterToString(department)}" + Environment.NewLine);
+ callText.Append("-----Nature-----" + Environment.NewLine);
+ callText.Append(call.NatureOfCall + Environment.NewLine);
+ callText.Append("-----Address-----" + Environment.NewLine);
+
+ if (!String.IsNullOrWhiteSpace(call.Address))
+ callText.Append(call.Address + Environment.NewLine);
+ else if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1)
+ {
+ try
+ {
+ string[] points = call.GeoLocationData.Split(char.Parse(","));
+
+ if (points != null && points.Length == 2)
+ {
+ callText.Append(_geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])) + Environment.NewLine);
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ response.Message(callText.ToString());
+ break;
+ }
+ }
+ }
+ }
+ }
+ else if (textMessage.To == Config.NumberProviderConfig.TwilioResgridNumber) // Resgrid master text number
+ {
+ var profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn);
+ var payload = _textCommandService.DetermineType(textMessage.Text);
+
+ switch (payload.Type)
+ {
+ case TextCommandTypes.None:
+ response.Message("Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands.");
+ break;
+ case TextCommandTypes.Help:
+ messageEvent.Processed = true;
+
+ var help = new StringBuilder();
+ help.Append("Resgrid Text Commands" + Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("This is the Resgrid system for first responders (https://resgrid.com) automated text system. Your department isn't signed up for inbound text messages, but you can send the following commands." +
+ Environment.NewLine);
+ help.Append("---------------------" + Environment.NewLine);
+ help.Append("STOP: To turn off all text messages" + Environment.NewLine);
+ help.Append("HELP: This help text" + Environment.NewLine);
+
+ response.Message(help.ToString());
+
+ break;
+ case TextCommandTypes.Stop:
+ messageEvent.Processed = true;
+
+ if (profile == null)
+ {
+ response.Message("Unable to locate your profile. Please log in to Resgrid to manage your text message settings.");
+ break;
+ }
+
+ await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId);
+ response.Message("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.");
+ break;
+ }
+ }
+ }
+
[HttpGet("VoiceCall")]
[Produces("application/xml")]
[ValidateRequest]
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FeatureTogglesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FeatureTogglesController.cs
new file mode 100644
index 000000000..de02ccfc9
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Controllers/v4/FeatureTogglesController.cs
@@ -0,0 +1,643 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+using Resgrid.Providers.Claims;
+using Resgrid.Web.Services.Helpers;
+using Resgrid.Web.Services.Models.v4.FeatureToggles;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Web.Services.Controllers.v4
+{
+ ///
+ /// Built-in feature toggle API. The poll endpoints are available to any authenticated user and are
+ /// scoped to that user's department. Flag/targeting/prerequisite/analytics management requires
+ /// SystemAdmin; per-department override management is available to system administrators (any
+ /// department) and to a department's own administrators (their department only).
+ ///
+ [Route("api/v{VersionId:apiVersion}/[controller]")]
+ [ApiVersion("4.0")]
+ [ApiExplorerSettings(GroupName = "v4")]
+ public class FeatureTogglesController : V4AuthenticatedApiControllerbase
+ {
+ private readonly IFeatureToggleService _featureToggleService;
+
+ public FeatureTogglesController(IFeatureToggleService featureToggleService)
+ {
+ _featureToggleService = featureToggleService;
+ }
+
+ private bool IsSystemAdmin => User.IsInRole("Admins");
+
+ private bool IsDepartmentAdmin => User.HasClaim(ResgridClaimTypes.Resources.Department, ResgridClaimTypes.Actions.Update);
+
+ #region Poll / evaluation (any authenticated user, scoped to the caller's department)
+
+ /// Evaluates every active flag for the caller's department. Supports ETag/If-None-Match polling.
+ [HttpGet("GetAll")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status304NotModified)]
+ [Authorize]
+ public async Task> GetAll()
+ {
+ var hash = await _featureToggleService.GetDepartmentFlagStateHashAsync(DepartmentId);
+ var etag = "\"" + hash + "\"";
+ Response.Headers["ETag"] = etag;
+
+ var ifNoneMatch = Request.Headers["If-None-Match"].ToString();
+ if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch == etag)
+ return StatusCode(StatusCodes.Status304NotModified);
+
+ var evaluations = await _featureToggleService.EvaluateAllForDepartmentAsync(DepartmentId);
+
+ var result = new FeatureTogglesResult { StateHash = hash };
+ foreach (var evaluation in evaluations)
+ result.Data.Add(MapEvaluation(evaluation));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ /// Evaluates a single flag for the caller's department.
+ [HttpGet("Get")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize]
+ public async Task> Get(string key)
+ {
+ var evaluation = await _featureToggleService.EvaluateAsync(key, DepartmentId);
+
+ var result = new FeatureToggleResult { Data = MapEvaluation(evaluation) };
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ /// Lightweight enabled-only check for a single flag.
+ [HttpGet("GetState")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize]
+ public async Task> GetState(string key)
+ {
+ var enabled = await _featureToggleService.IsEnabledAsync(key, DepartmentId);
+
+ var result = new FeatureToggleResult { Data = new FeatureToggleData { Key = key, Enabled = enabled } };
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ #endregion
+
+ #region Flag management (SystemAdmin)
+
+ [HttpGet("GetFlags")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> GetFlags(bool includeArchived = true)
+ {
+ var flags = await _featureToggleService.GetAllFlagsAsync(includeArchived, bypassCache: true);
+
+ var result = new FeatureFlagsResult();
+ foreach (var flag in flags)
+ result.Data.Add(MapFlag(flag));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpGet("GetFlag")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> GetFlag(string key)
+ {
+ var flag = await _featureToggleService.GetFlagByKeyAsync(key, bypassCache: true);
+
+ var result = new FeatureFlagResult();
+ if (flag == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data = MapFlag(flag);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("SaveFlag")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> SaveFlag([FromBody] SaveFeatureFlagInput input)
+ {
+ if (input == null || string.IsNullOrWhiteSpace(input.Key) || string.IsNullOrWhiteSpace(input.Name))
+ return BadRequest("Key and Name are required.");
+
+ var existing = await _featureToggleService.GetFlagByKeyAsync(input.Key, bypassCache: true);
+ var isNew = existing == null;
+ var flag = existing ?? new FeatureFlag();
+
+ flag.FlagKey = input.Key;
+ flag.Name = input.Name;
+ flag.Description = input.Description;
+ flag.Category = input.Category;
+ flag.Tags = input.Tags;
+ flag.FlagType = input.FlagType;
+ flag.IsEnabledGlobally = input.IsEnabledGlobally;
+ flag.DefaultValue = input.DefaultValue;
+ flag.OffValue = input.OffValue;
+ flag.RolloutPercentage = input.RolloutPercentage;
+ flag.MinimumPlanType = input.MinimumPlanType;
+ flag.Environment = input.Environment;
+ flag.EnableOn = input.EnableOn;
+ flag.DisableOn = input.DisableOn;
+ flag.IsArchived = input.IsArchived;
+ flag.IsPermanent = input.IsPermanent;
+
+ var saved = await _featureToggleService.SaveFlagAsync(flag, UserId);
+
+ var result = new FeatureFlagResult { Data = MapFlag(saved) };
+ result.PageSize = 1;
+ result.Status = isNew ? ResponseHelper.Created : ResponseHelper.Updated;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("ArchiveFlag")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> ArchiveFlag(string key, bool archived = true)
+ {
+ var ok = await _featureToggleService.ArchiveFlagAsync(key, UserId, archived);
+
+ var result = new FeatureFlagResult();
+ if (!ok)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data = MapFlag(await _featureToggleService.GetFlagByKeyAsync(key, bypassCache: true));
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Updated;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("SetGlobalEnabled")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> SetGlobalEnabled([FromBody] SetGlobalEnabledInput input)
+ {
+ if (input == null || string.IsNullOrWhiteSpace(input.Key))
+ return BadRequest("Key is required.");
+
+ var saved = await _featureToggleService.SetGlobalEnabledAsync(input.Key, input.Enabled, UserId);
+
+ var result = new FeatureFlagResult();
+ if (saved == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data = MapFlag(saved);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Updated;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("SetRollout")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> SetRollout([FromBody] SetRolloutInput input)
+ {
+ if (input == null || string.IsNullOrWhiteSpace(input.Key))
+ return BadRequest("Key is required.");
+
+ var saved = await _featureToggleService.SetRolloutPercentageAsync(input.Key, input.Percentage, UserId);
+
+ var result = new FeatureFlagResult();
+ if (saved == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data = MapFlag(saved);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Updated;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpDelete("DeleteFlag")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> DeleteFlag(string key)
+ {
+ var ok = await _featureToggleService.DeleteFlagAsync(key, UserId);
+
+ var result = new FeatureFlagResult();
+ if (!ok)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Status = ResponseHelper.Deleted;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ #endregion
+
+ #region Targeting rules & prerequisites (SystemAdmin)
+
+ [HttpGet("GetTargetingRules")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> GetTargetingRules(string key)
+ {
+ var rules = await _featureToggleService.GetTargetingRulesForFlagAsync(key);
+
+ var result = new FeatureFlagTargetingRulesResult();
+ foreach (var rule in rules)
+ result.Data.Add(MapRule(rule));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("SaveTargetingRule")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> SaveTargetingRule([FromBody] SaveTargetingRuleInput input)
+ {
+ if (input == null || string.IsNullOrWhiteSpace(input.Key))
+ return BadRequest("Key is required.");
+
+ var flag = await _featureToggleService.GetFlagByKeyAsync(input.Key, bypassCache: true);
+ if (flag == null)
+ {
+ var notFound = new FeatureFlagTargetingRulesResult();
+ ResponseHelper.PopulateV4ResponseNotFound(notFound);
+ return notFound;
+ }
+
+ var rule = new FeatureFlagTargetingRule
+ {
+ FeatureFlagTargetingRuleId = input.FeatureFlagTargetingRuleId,
+ FeatureFlagId = flag.FeatureFlagId,
+ Priority = input.Priority,
+ AttributeType = input.AttributeType,
+ OperatorType = input.OperatorType,
+ ComparisonValue = input.ComparisonValue,
+ ResultEnabled = input.ResultEnabled,
+ ResultValue = input.ResultValue,
+ RolloutPercentage = input.RolloutPercentage
+ };
+
+ var saved = await _featureToggleService.SaveTargetingRuleAsync(rule, UserId);
+
+ var result = new FeatureFlagTargetingRulesResult();
+ result.Data.Add(MapRule(saved));
+ result.PageSize = 1;
+ result.Status = input.FeatureFlagTargetingRuleId == 0 ? ResponseHelper.Created : ResponseHelper.Updated;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpDelete("DeleteTargetingRule")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> DeleteTargetingRule(int id)
+ {
+ var ok = await _featureToggleService.RemoveTargetingRuleAsync(id, UserId);
+
+ var result = new FeatureFlagTargetingRulesResult();
+ if (!ok)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Status = ResponseHelper.Deleted;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpGet("GetPrerequisites")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> GetPrerequisites(string key)
+ {
+ var prereqs = await _featureToggleService.GetPrerequisitesForFlagAsync(key);
+
+ var result = new FeatureFlagPrerequisitesResult();
+ foreach (var prereq in prereqs)
+ result.Data.Add(MapPrerequisite(prereq));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("AddPrerequisite")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> AddPrerequisite([FromBody] AddPrerequisiteInput input)
+ {
+ if (input == null || string.IsNullOrWhiteSpace(input.Key) || string.IsNullOrWhiteSpace(input.RequiredKey))
+ return BadRequest("Key and RequiredKey are required.");
+
+ FeatureFlagPrerequisite saved;
+ try
+ {
+ saved = await _featureToggleService.AddPrerequisiteAsync(input.Key, input.RequiredKey, input.RequiredValue, UserId);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+
+ var result = new FeatureFlagPrerequisitesResult();
+ result.Data.Add(MapPrerequisite(saved));
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Created;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpDelete("DeletePrerequisite")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> DeletePrerequisite(int id)
+ {
+ var ok = await _featureToggleService.RemovePrerequisiteAsync(id, UserId);
+
+ var result = new FeatureFlagPrerequisitesResult();
+ if (!ok)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Status = ResponseHelper.Deleted;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ #endregion
+
+ #region Override management (SystemAdmin or department admin for own department)
+
+ [HttpGet("GetOverrides")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize]
+ public async Task> GetOverrides(int? departmentId = null)
+ {
+ if (!IsSystemAdmin && !IsDepartmentAdmin)
+ return Unauthorized();
+
+ var targetDepartmentId = ResolveTargetDepartmentId(departmentId);
+ var overrides = await _featureToggleService.GetOverridesForDepartmentAsync(targetDepartmentId, bypassCache: true);
+
+ var result = new FeatureFlagOverridesResult();
+ foreach (var ovr in overrides)
+ result.Data.Add(MapOverride(ovr));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpPost("SetOverride")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize]
+ public async Task> SetOverride([FromBody] SetFeatureFlagOverrideInput input)
+ {
+ if (input == null || string.IsNullOrWhiteSpace(input.Key))
+ return BadRequest("Key is required.");
+ if (!IsSystemAdmin && !IsDepartmentAdmin)
+ return Unauthorized();
+
+ var targetDepartmentId = ResolveTargetDepartmentId(input.DepartmentId);
+
+ FeatureFlagOverride saved;
+ try
+ {
+ saved = await _featureToggleService.SetDepartmentOverrideAsync(input.Key, targetDepartmentId, input.IsEnabled, input.Value, input.Reason, input.ExpiresOn, UserId);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+
+ var result = new FeatureFlagOverrideResult { Data = MapOverride(saved) };
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Updated;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpDelete("RemoveOverride")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize]
+ public async Task> RemoveOverride(string key, int? departmentId = null)
+ {
+ if (!IsSystemAdmin && !IsDepartmentAdmin)
+ return Unauthorized();
+
+ var targetDepartmentId = ResolveTargetDepartmentId(departmentId);
+ var ok = await _featureToggleService.RemoveDepartmentOverrideAsync(key, targetDepartmentId, UserId);
+
+ var result = new FeatureFlagOverrideResult();
+ if (!ok)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Status = ResponseHelper.Deleted;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ #endregion
+
+ #region Analytics (SystemAdmin)
+
+ [HttpGet("GetUsage")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> GetUsage(string key, DateTime? from = null, DateTime? to = null)
+ {
+ var fromDate = from ?? DateTime.UtcNow.AddDays(-30);
+ var toDate = to ?? DateTime.UtcNow;
+
+ var usages = await _featureToggleService.GetUsageForFlagAsync(key, fromDate, toDate);
+
+ var result = new FeatureFlagUsageResult();
+ foreach (var usage in usages)
+ result.Data.Add(MapUsage(usage));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ [HttpGet("GetStaleFlags")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.SystemAdmin)]
+ public async Task> GetStaleFlags(int? olderThanDays = null)
+ {
+ var flags = await _featureToggleService.GetStaleFlagsAsync(olderThanDays);
+
+ var result = new FeatureFlagsResult();
+ foreach (var flag in flags)
+ result.Data.Add(MapFlag(flag));
+
+ result.PageSize = result.Data.Count;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
+ #endregion
+
+ #region Helpers & mapping
+
+ private int ResolveTargetDepartmentId(int? requested)
+ {
+ if (IsSystemAdmin && requested.HasValue && requested.Value > 0)
+ return requested.Value;
+ return DepartmentId;
+ }
+
+ private static FeatureToggleData MapEvaluation(FeatureFlagEvaluation evaluation)
+ {
+ return new FeatureToggleData
+ {
+ Key = evaluation.Key,
+ Enabled = evaluation.IsEnabled,
+ Value = evaluation.Value,
+ ValueType = evaluation.ValueType.ToString(),
+ Source = evaluation.Source.ToString(),
+ MatchedRuleId = evaluation.MatchedRuleId
+ };
+ }
+
+ private static FeatureFlagData MapFlag(FeatureFlag flag)
+ {
+ if (flag == null)
+ return null;
+
+ return new FeatureFlagData
+ {
+ FeatureFlagId = flag.FeatureFlagId.ToString(),
+ Key = flag.FlagKey,
+ Name = flag.Name,
+ Description = flag.Description,
+ Category = flag.Category,
+ Tags = flag.Tags,
+ FlagType = flag.FlagType,
+ FlagTypeName = ((FeatureFlagValueTypes)flag.FlagType).ToString(),
+ IsEnabledGlobally = flag.IsEnabledGlobally,
+ DefaultValue = flag.DefaultValue,
+ OffValue = flag.OffValue,
+ RolloutPercentage = flag.RolloutPercentage,
+ MinimumPlanType = flag.MinimumPlanType,
+ Environment = flag.Environment,
+ EnableOn = flag.EnableOn?.ToString("O"),
+ DisableOn = flag.DisableOn?.ToString("O"),
+ IsArchived = flag.IsArchived,
+ IsPermanent = flag.IsPermanent,
+ LastEvaluatedOn = flag.LastEvaluatedOn?.ToString("O"),
+ CreatedOn = flag.CreatedOn.ToString("O"),
+ UpdatedOn = flag.UpdatedOn?.ToString("O")
+ };
+ }
+
+ private static FeatureFlagOverrideData MapOverride(FeatureFlagOverride ovr)
+ {
+ if (ovr == null)
+ return null;
+
+ return new FeatureFlagOverrideData
+ {
+ FeatureFlagOverrideId = ovr.FeatureFlagOverrideId.ToString(),
+ FeatureFlagId = ovr.FeatureFlagId.ToString(),
+ DepartmentId = ovr.DepartmentId,
+ IsEnabled = ovr.IsEnabled,
+ Value = ovr.FlagValue,
+ Reason = ovr.Reason,
+ ExpiresOn = ovr.ExpiresOn?.ToString("O")
+ };
+ }
+
+ private static FeatureFlagTargetingRuleData MapRule(FeatureFlagTargetingRule rule)
+ {
+ if (rule == null)
+ return null;
+
+ return new FeatureFlagTargetingRuleData
+ {
+ FeatureFlagTargetingRuleId = rule.FeatureFlagTargetingRuleId.ToString(),
+ FeatureFlagId = rule.FeatureFlagId.ToString(),
+ Priority = rule.Priority,
+ AttributeType = rule.AttributeType,
+ OperatorType = rule.OperatorType,
+ ComparisonValue = rule.ComparisonValue,
+ ResultEnabled = rule.ResultEnabled,
+ ResultValue = rule.ResultValue,
+ RolloutPercentage = rule.RolloutPercentage
+ };
+ }
+
+ private static FeatureFlagPrerequisiteData MapPrerequisite(FeatureFlagPrerequisite prereq)
+ {
+ if (prereq == null)
+ return null;
+
+ return new FeatureFlagPrerequisiteData
+ {
+ FeatureFlagPrerequisiteId = prereq.FeatureFlagPrerequisiteId.ToString(),
+ FeatureFlagId = prereq.FeatureFlagId.ToString(),
+ RequiredFeatureFlagId = prereq.RequiredFeatureFlagId.ToString(),
+ RequiredValue = prereq.RequiredValue
+ };
+ }
+
+ private static FeatureFlagUsageData MapUsage(FeatureFlagUsage usage)
+ {
+ return new FeatureFlagUsageData
+ {
+ FeatureFlagId = usage.FeatureFlagId.ToString(),
+ DepartmentId = usage.DepartmentId,
+ UsageDate = usage.UsageDate.ToString("O"),
+ EvaluationCount = usage.EvaluationCount,
+ EnabledCount = usage.EnabledCount,
+ DisabledCount = usage.DisabledCount
+ };
+ }
+
+ #endregion
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Models/v4/FeatureToggles/FeatureToggleInputs.cs b/Web/Resgrid.Web.Services/Models/v4/FeatureToggles/FeatureToggleInputs.cs
new file mode 100644
index 000000000..9be345163
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Models/v4/FeatureToggles/FeatureToggleInputs.cs
@@ -0,0 +1,68 @@
+using System;
+
+namespace Resgrid.Web.Services.Models.v4.FeatureToggles
+{
+ /// Create or update a feature flag definition (matched by Key).
+ public class SaveFeatureFlagInput
+ {
+ public string Key { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string Category { get; set; }
+ public string Tags { get; set; }
+ public int FlagType { get; set; }
+ public bool IsEnabledGlobally { get; set; }
+ public string DefaultValue { get; set; }
+ public string OffValue { get; set; }
+ public int? RolloutPercentage { get; set; }
+ public int? MinimumPlanType { get; set; }
+ public int? Environment { get; set; }
+ public DateTime? EnableOn { get; set; }
+ public DateTime? DisableOn { get; set; }
+ public bool IsArchived { get; set; }
+ public bool IsPermanent { get; set; }
+ }
+
+ public class SetGlobalEnabledInput
+ {
+ public string Key { get; set; }
+ public bool Enabled { get; set; }
+ }
+
+ public class SetRolloutInput
+ {
+ public string Key { get; set; }
+ public int Percentage { get; set; }
+ }
+
+ public class SetFeatureFlagOverrideInput
+ {
+ public string Key { get; set; }
+ /// Target department. Honored only for system administrators; otherwise the caller's department is used.
+ public int? DepartmentId { get; set; }
+ public bool IsEnabled { get; set; }
+ public string Value { get; set; }
+ public string Reason { get; set; }
+ public DateTime? ExpiresOn { get; set; }
+ }
+
+ public class SaveTargetingRuleInput
+ {
+ public int FeatureFlagTargetingRuleId { get; set; }
+ public string Key { get; set; }
+ public int Priority { get; set; }
+ public int AttributeType { get; set; }
+ public int OperatorType { get; set; }
+ public string ComparisonValue { get; set; }
+ public bool ResultEnabled { get; set; }
+ public string ResultValue { get; set; }
+ public int? RolloutPercentage { get; set; }
+ }
+
+ public class AddPrerequisiteInput
+ {
+ public string Key { get; set; }
+ public string RequiredKey { get; set; }
+ public string RequiredValue { get; set; }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Models/v4/FeatureToggles/FeatureToggleResults.cs b/Web/Resgrid.Web.Services/Models/v4/FeatureToggles/FeatureToggleResults.cs
new file mode 100644
index 000000000..ea281ab85
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Models/v4/FeatureToggles/FeatureToggleResults.cs
@@ -0,0 +1,150 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Web.Services.Models.v4.FeatureToggles
+{
+ #region Poll / evaluation results
+
+ /// Resolved state of a single feature toggle for the calling department.
+ public class FeatureToggleData
+ {
+ public string Key { get; set; }
+ public bool Enabled { get; set; }
+ public string Value { get; set; }
+ public string ValueType { get; set; }
+ public string Source { get; set; }
+ public int? MatchedRuleId { get; set; }
+ }
+
+ public class FeatureToggleResult : StandardApiResponseV4Base
+ {
+ public FeatureToggleData Data { get; set; } = new FeatureToggleData();
+ }
+
+ public class FeatureTogglesResult : StandardApiResponseV4Base
+ {
+ public List Data { get; set; } = new List();
+
+ /// Stable hash of the full resolved state (also returned as the ETag header).
+ public string StateHash { get; set; }
+ }
+
+ #endregion
+
+ #region Flag management results
+
+ public class FeatureFlagData
+ {
+ public string FeatureFlagId { get; set; }
+ public string Key { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string Category { get; set; }
+ public string Tags { get; set; }
+ public int FlagType { get; set; }
+ public string FlagTypeName { get; set; }
+ public bool IsEnabledGlobally { get; set; }
+ public string DefaultValue { get; set; }
+ public string OffValue { get; set; }
+ public int? RolloutPercentage { get; set; }
+ public int? MinimumPlanType { get; set; }
+ public int? Environment { get; set; }
+ public string EnableOn { get; set; }
+ public string DisableOn { get; set; }
+ public bool IsArchived { get; set; }
+ public bool IsPermanent { get; set; }
+ public string LastEvaluatedOn { get; set; }
+ public string CreatedOn { get; set; }
+ public string UpdatedOn { get; set; }
+ }
+
+ public class FeatureFlagResult : StandardApiResponseV4Base
+ {
+ public FeatureFlagData Data { get; set; } = new FeatureFlagData();
+ }
+
+ public class FeatureFlagsResult : StandardApiResponseV4Base
+ {
+ public List Data { get; set; } = new List();
+ }
+
+ #endregion
+
+ #region Override results
+
+ public class FeatureFlagOverrideData
+ {
+ public string FeatureFlagOverrideId { get; set; }
+ public string FeatureFlagId { get; set; }
+ public int DepartmentId { get; set; }
+ public bool IsEnabled { get; set; }
+ public string Value { get; set; }
+ public string Reason { get; set; }
+ public string ExpiresOn { get; set; }
+ }
+
+ public class FeatureFlagOverrideResult : StandardApiResponseV4Base
+ {
+ public FeatureFlagOverrideData Data { get; set; }
+ }
+
+ public class FeatureFlagOverridesResult : StandardApiResponseV4Base
+ {
+ public List Data { get; set; } = new List();
+ }
+
+ #endregion
+
+ #region Targeting rule & prerequisite results
+
+ public class FeatureFlagTargetingRuleData
+ {
+ public string FeatureFlagTargetingRuleId { get; set; }
+ public string FeatureFlagId { get; set; }
+ public int Priority { get; set; }
+ public int AttributeType { get; set; }
+ public int OperatorType { get; set; }
+ public string ComparisonValue { get; set; }
+ public bool ResultEnabled { get; set; }
+ public string ResultValue { get; set; }
+ public int? RolloutPercentage { get; set; }
+ }
+
+ public class FeatureFlagTargetingRulesResult : StandardApiResponseV4Base
+ {
+ public List Data { get; set; } = new List();
+ }
+
+ public class FeatureFlagPrerequisiteData
+ {
+ public string FeatureFlagPrerequisiteId { get; set; }
+ public string FeatureFlagId { get; set; }
+ public string RequiredFeatureFlagId { get; set; }
+ public string RequiredValue { get; set; }
+ }
+
+ public class FeatureFlagPrerequisitesResult : StandardApiResponseV4Base
+ {
+ public List Data { get; set; } = new List();
+ }
+
+ #endregion
+
+ #region Analytics results
+
+ public class FeatureFlagUsageData
+ {
+ public string FeatureFlagId { get; set; }
+ public int? DepartmentId { get; set; }
+ public string UsageDate { get; set; }
+ public long EvaluationCount { get; set; }
+ public long EnabledCount { get; set; }
+ public long DisabledCount { get; set; }
+ }
+
+ public class FeatureFlagUsageResult : StandardApiResponseV4Base
+ {
+ public List Data { get; set; } = new List();
+ }
+
+ #endregion
+}
diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
index 4bf1a829d..5a68f719b 100644
--- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
+++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
@@ -845,6 +845,23 @@
Array of CallTemplateResult objects for each role in the department
+
+
+ Built-in feature toggle API. The poll endpoints are available to any authenticated user and are
+ scoped to that user's department. Flag/targeting/prerequisite/analytics management requires
+ SystemAdmin; per-department override management is available to system administrators (any
+ department) and to a department's own administrators (their department only).
+
+
+
+ Evaluates every active flag for the caller's department. Supports ETag/If-None-Match polling.
+
+
+ Evaluates a single flag for the caller's department.
+
+
+ Lightweight enabled-only check for a single flag.
+
Gets calls and other data formatted for different feed formats, like RSS.
@@ -6882,6 +6899,18 @@
All the data required to populate the New Call form
+
+ Create or update a feature flag definition (matched by Key).
+
+
+ Target department. Honored only for system administrators; otherwise the caller's department is used.
+
+
+ Resolved state of a single feature toggle for the calling department.
+
+
+ Stable hash of the full resolved state (also returned as the ETag header).
+
Depicts a user created form.
diff --git a/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs b/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs
new file mode 100644
index 000000000..bc0fe1c17
--- /dev/null
+++ b/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Autofac;
+using Resgrid.Framework;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Workers.Framework.Logic
+{
+ ///
+ /// Flushes the feature toggle service's in-memory evaluation counters to the FeatureFlagUsages
+ /// table and refreshes each touched flag's LastEvaluatedOn (used for stale-flag detection).
+ ///
+ /// Follows the standard worker Logic Process() pattern and resolves the service through the
+ /// Service-Locator/Autofac container. It is intended to be invoked on a recurring cadence by the
+ /// worker host (the same Quidjibo scheduling that drives the other recurring processors), roughly
+ /// every FeatureFlagsConfig.EvaluationFlushIntervalSeconds. The flush is idempotent and append-only,
+ /// so an occasional missed or duplicated run only affects aggregated analytics, never evaluation.
+ ///
+ public class FeatureToggleUsageProcessor
+ {
+ public async Task> Process(CancellationToken cancellationToken = default(CancellationToken))
+ {
+ try
+ {
+ var featureToggleService = Bootstrapper.GetKernel().Resolve();
+ var flushed = await featureToggleService.FlushEvaluationsAsync(cancellationToken);
+
+ return new Tuple(true, $"Flushed {flushed} feature toggle usage record(s).");
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ return new Tuple(false, ex.ToString());
+ }
+ }
+ }
+}