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()); + } + } + } +}