Skip to content

Commit 443333f

Browse files
authored
Merge pull request #394 from Resgrid/develop
Develop
2 parents a4fa86e + 60ff082 commit 443333f

39 files changed

Lines changed: 3445 additions & 17 deletions

.coderabbit.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ reviews:
3737
- "!**/*.Designer.cs"
3838
- "!**/bin/**"
3939
- "!**/obj/**"
40+
- "!**/Tests/**"
41+
- "!**/.claude/**"
42+
- "!**/*.md"
4043
path_instructions: []
4144
abort_on_close: true
4245
disable_cache: false
@@ -230,4 +233,4 @@ issue_enrichment:
230233
labels: []
231234
labeling:
232235
labeling_instructions: []
233-
auto_apply_labels: false
236+
auto_apply_labels: false
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace Resgrid.Config
4+
{
5+
/// <summary>
6+
/// Global configuration for the built-in feature toggle subsystem. Loaded by ConfigProcessor via
7+
/// reflection (keys: "FeatureFlagsConfig.Field" in JSON or "RESGRID:FeatureFlagsConfig:Field" env).
8+
/// </summary>
9+
public static class FeatureFlagsConfig
10+
{
11+
/// <summary>
12+
/// Master switch for the whole subsystem. When false, evaluations short-circuit to the
13+
/// caller-supplied (or code-registered) default and no flag/override data is consulted.
14+
/// </summary>
15+
public static bool FeatureFlagsEnabled = true;
16+
17+
/// <summary>
18+
/// How long the flag set and per-department overrides are cached. Flag/override writes invalidate
19+
/// the relevant cache immediately, so this can be generous.
20+
/// </summary>
21+
public static int CacheDurationMinutes = 60;
22+
23+
/// <summary>
24+
/// When true, evaluations increment in-memory counters that the usage-flush worker persists to
25+
/// FeatureFlagUsages and uses to refresh LastEvaluatedOn (for stale-flag detection).
26+
/// </summary>
27+
public static bool TrackEvaluations = true;
28+
29+
/// <summary>How often the usage-flush worker drains the in-memory evaluation counters.</summary>
30+
public static int EvaluationFlushIntervalSeconds = 60;
31+
32+
/// <summary>Non-permanent flags not evaluated within this many days are reported as stale.</summary>
33+
public static int StaleFlagThresholdDays = 90;
34+
35+
/// <summary>
36+
/// Code-registered boolean defaults keyed by flag key. Used as the fallback when a flag has not
37+
/// yet been seeded in the database, so new flags behave predictably before they exist as rows.
38+
/// </summary>
39+
public static ConcurrentDictionary<string, bool> CodeDefaults = new ConcurrentDictionary<string, bool>();
40+
}
41+
}

Core/Resgrid.Model/AuditLogTypes.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ public enum AuditLogTypes
160160
WeatherAlertZoneDeleted,
161161
WeatherAlertZoneEnabled,
162162
WeatherAlertZoneDisabled,
163-
WeatherAlertSettingsChanged
163+
WeatherAlertSettingsChanged,
164+
// Feature Toggles
165+
FeatureFlagChanged,
166+
FeatureFlagOverrideChanged
164167
}
165168
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Resgrid.Model
2+
{
3+
/// <summary>
4+
/// Well-known feature flag keys consumed by application code. Keys are matched case-insensitively by
5+
/// <see cref="Services.IFeatureToggleService"/>. When adding a key here, seed a matching flag row via a
6+
/// migration (see M0072) so the flag is immediately manageable from the admin UI/API.
7+
/// </summary>
8+
public static class FeatureFlagKeys
9+
{
10+
/// <summary>
11+
/// Routes inbound Twilio SMS through the new chatbot ingress pipeline. When off (globally or for a
12+
/// specific department) the original text-command handling in TwilioController is used instead.
13+
/// </summary>
14+
public const string ChatbotTwilioTextIntegration = "Chatbot.TwilioTextIntegration";
15+
}
16+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel.DataAnnotations;
4+
using System.ComponentModel.DataAnnotations.Schema;
5+
using Newtonsoft.Json;
6+
using ProtoBuf;
7+
8+
namespace Resgrid.Model
9+
{
10+
/// <summary>
11+
/// A system-wide feature flag definition with a global default. Per-department behavior is
12+
/// layered on top via <see cref="FeatureFlagOverride"/>, <see cref="FeatureFlagTargetingRule"/>
13+
/// and <see cref="FeatureFlagPrerequisite"/>.
14+
/// </summary>
15+
[Table("FeatureFlags")]
16+
[ProtoContract]
17+
public class FeatureFlag : IEntity
18+
{
19+
[Key]
20+
[Required]
21+
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
22+
[ProtoMember(1)]
23+
public int FeatureFlagId { get; set; }
24+
25+
/// <summary>Stable identifier referenced by code and clients (e.g. "new-dispatch-ui").</summary>
26+
[Required]
27+
[ProtoMember(2)]
28+
public string FlagKey { get; set; }
29+
30+
[Required]
31+
[ProtoMember(3)]
32+
public string Name { get; set; }
33+
34+
[ProtoMember(4)]
35+
public string Description { get; set; }
36+
37+
[ProtoMember(5)]
38+
public string Category { get; set; }
39+
40+
/// <summary>Comma-separated free-form tags for grouping/searching.</summary>
41+
[ProtoMember(6)]
42+
public string Tags { get; set; }
43+
44+
/// <summary>Backing int for <see cref="FeatureFlagValueTypes"/>.</summary>
45+
[Required]
46+
[ProtoMember(7)]
47+
public int FlagType { get; set; }
48+
49+
/// <summary>The global on/off default and application-wide kill switch.</summary>
50+
[Required]
51+
[ProtoMember(8)]
52+
public bool IsEnabledGlobally { get; set; }
53+
54+
/// <summary>Value returned when the flag resolves "on" for multivariate flags.</summary>
55+
[ProtoMember(9)]
56+
public string DefaultValue { get; set; }
57+
58+
/// <summary>Value returned when the flag resolves "off" for multivariate flags.</summary>
59+
[ProtoMember(10)]
60+
public string OffValue { get; set; }
61+
62+
/// <summary>0-100 gradual rollout across departments when globally on; null = 100%.</summary>
63+
[ProtoMember(11)]
64+
public int? RolloutPercentage { get; set; }
65+
66+
/// <summary>Optional minimum subscription plan id required for the flag to be on.</summary>
67+
[ProtoMember(12)]
68+
public int? MinimumPlanType { get; set; }
69+
70+
/// <summary>Optional environment scope (backing int for SystemEnvironment); null = all.</summary>
71+
[ProtoMember(13)]
72+
public int? Environment { get; set; }
73+
74+
[ProtoMember(14)]
75+
public DateTime? EnableOn { get; set; }
76+
77+
[ProtoMember(15)]
78+
public DateTime? DisableOn { get; set; }
79+
80+
[Required]
81+
[ProtoMember(16)]
82+
public bool IsArchived { get; set; }
83+
84+
/// <summary>Permanent flags are excluded from stale-flag detection.</summary>
85+
[Required]
86+
[ProtoMember(17)]
87+
public bool IsPermanent { get; set; }
88+
89+
[ProtoMember(18)]
90+
public DateTime? LastEvaluatedOn { get; set; }
91+
92+
[Required]
93+
[ProtoMember(19)]
94+
public DateTime CreatedOn { get; set; }
95+
96+
[ProtoMember(20)]
97+
public string CreatedByUserId { get; set; }
98+
99+
[ProtoMember(21)]
100+
public DateTime? UpdatedOn { get; set; }
101+
102+
[ProtoMember(22)]
103+
public string UpdatedByUserId { get; set; }
104+
105+
[NotMapped]
106+
[JsonIgnore]
107+
public object IdValue
108+
{
109+
get { return FeatureFlagId; }
110+
set { FeatureFlagId = (int)value; }
111+
}
112+
113+
[NotMapped]
114+
public string TableName => "FeatureFlags";
115+
116+
[NotMapped]
117+
public string IdName => "FeatureFlagId";
118+
119+
[NotMapped]
120+
public int IdType => 0;
121+
122+
[NotMapped]
123+
public IEnumerable<string> IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
124+
}
125+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Resgrid.Model
2+
{
3+
/// <summary>
4+
/// The department attribute a targeting rule compares against. Resolved lazily (and cached) during
5+
/// evaluation from the department, its subscription plan, and personnel counts.
6+
/// </summary>
7+
public enum FeatureFlagAttributeTypes
8+
{
9+
/// <summary>The department's current subscription plan id.</summary>
10+
PlanType = 0,
11+
/// <summary>The department's country/region.</summary>
12+
Country = 1,
13+
/// <summary>The department's active personnel count.</summary>
14+
PersonnelCount = 2,
15+
/// <summary>The department's type (e.g. fire, ems).</summary>
16+
DepartmentType = 3,
17+
/// <summary>The department's creation date.</summary>
18+
CreatedDate = 4,
19+
/// <summary>The department id itself (allow/deny lists).</summary>
20+
DepartmentId = 5,
21+
/// <summary>A caller-supplied custom context value (matched by ComparisonValue key).</summary>
22+
Custom = 6,
23+
}
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Resgrid.Model
2+
{
3+
/// <summary>
4+
/// The resolved state of a feature flag for a specific department, including the reason it
5+
/// resolved that way. Returned by the feature toggle service and surfaced by the poll API.
6+
/// </summary>
7+
public class FeatureFlagEvaluation
8+
{
9+
public int FeatureFlagId { get; set; }
10+
11+
/// <summary>The flag's stable key.</summary>
12+
public string Key { get; set; }
13+
14+
public bool IsEnabled { get; set; }
15+
16+
/// <summary>Resolved value (for multivariate flags); for boolean flags mirrors IsEnabled.</summary>
17+
public string Value { get; set; }
18+
19+
public FeatureFlagValueTypes ValueType { get; set; }
20+
21+
/// <summary>Which rule in the evaluation ladder decided the result.</summary>
22+
public FeatureFlagEvaluationSource Source { get; set; }
23+
24+
/// <summary>The targeting rule id when <see cref="Source"/> is TargetingRule.</summary>
25+
public int? MatchedRuleId { get; set; }
26+
}
27+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
namespace Resgrid.Model
2+
{
3+
/// <summary>
4+
/// Explains which rule in the evaluation ladder decided a flag's value for a department. Returned
5+
/// on every evaluation so the poll API and logs can show LaunchDarkly-style "evaluation reasons".
6+
/// </summary>
7+
public enum FeatureFlagEvaluationSource
8+
{
9+
/// <summary>No flag with the requested key exists.</summary>
10+
NotFound = 0,
11+
/// <summary>Value came from a code-registered default in FeatureFlagsConfig.</summary>
12+
CodeDefault = 1,
13+
/// <summary>The whole feature-toggle subsystem is disabled via config.</summary>
14+
SubsystemDisabled = 2,
15+
/// <summary>The flag is archived.</summary>
16+
Archived = 3,
17+
/// <summary>Decided by the flag's scheduled enable/disable window.</summary>
18+
Schedule = 4,
19+
/// <summary>A prerequisite flag was not satisfied.</summary>
20+
Prerequisite = 5,
21+
/// <summary>An explicit per-department override decided the value.</summary>
22+
Override = 6,
23+
/// <summary>The department's subscription plan did not meet the flag's minimum plan.</summary>
24+
PlanGate = 7,
25+
/// <summary>A matching attribute/segment targeting rule decided the value.</summary>
26+
TargetingRule = 8,
27+
/// <summary>Decided by the global default and the percentage rollout bucket.</summary>
28+
GlobalRollout = 9,
29+
/// <summary>Decided by the global default (fully on/off, no rollout in effect).</summary>
30+
GlobalDefault = 10,
31+
}
32+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Resgrid.Model
2+
{
3+
/// <summary>
4+
/// The comparison applied by a targeting rule between a department attribute and the rule's
5+
/// ComparisonValue. In/NotIn treat ComparisonValue as a comma-separated list.
6+
/// </summary>
7+
public enum FeatureFlagOperatorTypes
8+
{
9+
Equals = 0,
10+
NotEquals = 1,
11+
In = 2,
12+
NotIn = 3,
13+
GreaterThan = 4,
14+
GreaterThanOrEqual = 5,
15+
LessThan = 6,
16+
LessThanOrEqual = 7,
17+
Contains = 8,
18+
}
19+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel.DataAnnotations;
4+
using System.ComponentModel.DataAnnotations.Schema;
5+
using Newtonsoft.Json;
6+
using ProtoBuf;
7+
8+
namespace Resgrid.Model
9+
{
10+
/// <summary>
11+
/// A per-department override of a feature flag's value. An explicit, non-expired override takes
12+
/// precedence over rollout and targeting rules.
13+
/// </summary>
14+
[Table("FeatureFlagOverrides")]
15+
[ProtoContract]
16+
public class FeatureFlagOverride : IEntity
17+
{
18+
[Key]
19+
[Required]
20+
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
21+
[ProtoMember(1)]
22+
public int FeatureFlagOverrideId { get; set; }
23+
24+
[Required]
25+
[ProtoMember(2)]
26+
public int FeatureFlagId { get; set; }
27+
28+
[Required]
29+
[ProtoMember(3)]
30+
public int DepartmentId { get; set; }
31+
32+
[Required]
33+
[ProtoMember(4)]
34+
public bool IsEnabled { get; set; }
35+
36+
/// <summary>Override variant value for multivariate flags.</summary>
37+
[ProtoMember(5)]
38+
public string FlagValue { get; set; }
39+
40+
[ProtoMember(6)]
41+
public string Reason { get; set; }
42+
43+
/// <summary>Optional expiry after which the override is ignored.</summary>
44+
[ProtoMember(7)]
45+
public DateTime? ExpiresOn { get; set; }
46+
47+
[Required]
48+
[ProtoMember(8)]
49+
public DateTime CreatedOn { get; set; }
50+
51+
[ProtoMember(9)]
52+
public string CreatedByUserId { get; set; }
53+
54+
[ProtoMember(10)]
55+
public DateTime? UpdatedOn { get; set; }
56+
57+
[ProtoMember(11)]
58+
public string UpdatedByUserId { get; set; }
59+
60+
[NotMapped]
61+
[JsonIgnore]
62+
public object IdValue
63+
{
64+
get { return FeatureFlagOverrideId; }
65+
set { FeatureFlagOverrideId = (int)value; }
66+
}
67+
68+
[NotMapped]
69+
public string TableName => "FeatureFlagOverrides";
70+
71+
[NotMapped]
72+
public string IdName => "FeatureFlagOverrideId";
73+
74+
[NotMapped]
75+
public int IdType => 0;
76+
77+
[NotMapped]
78+
public IEnumerable<string> IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
79+
}
80+
}

0 commit comments

Comments
 (0)